Switch to Structured JSON Logging for Containers¶
GTB defaults to logger.NewCharm for beautiful terminal output. When you deploy your tool as a daemon or container, you want structured JSON logs instead โ one JSON object per line, readable by Datadog, Loki, CloudWatch, or any other log aggregator.
This is a one-line change in main.go.
Step 1: Replace the Logger Backend in main.go¶
import (
"log/slog"
"os"
"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
"gitlab.com/phpboyscout/go-tool-base/pkg/props"
)
func main() {
// Detect whether we're running interactively or as a daemon
var l logger.Logger
if isTerminal(os.Stderr) {
// Interactive CLI: coloured, styled output
l = logger.NewCharm(os.Stderr,
logger.WithLevel(logger.InfoLevel),
logger.WithTimestamp(false),
)
} else {
// Daemon/container: structured JSON
l = logger.NewSlog(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
}
p := &props.Props{
Logger: l,
// ...
}
}
isTerminal can be implemented with golang.org/x/term:
Step 2: Configure the Log Level¶
SetLevel works on all backends:
Or wire it through config (after the config is loaded in PersistentPreRunE):
levelStr := p.Config.GetString("log.level")
if level, err := logger.ParseLevel(levelStr); err == nil {
p.Logger.SetLevel(level)
}
Valid level strings: debug, info, warn, error.
JSON Output Format¶
With slog.NewJSONHandler, each log call produces one JSON object:
{"time":"2026-03-25T14:23:01Z","level":"INFO","msg":"starting gRPC server","addr":":8080"}
{"time":"2026-03-25T14:23:01Z","level":"INFO","msg":"service registered","id":"grpc"}
{"time":"2026-03-25T14:23:05Z","level":"WARN","msg":"health check failed","service":"database","error":"connection refused"}
Structured fields passed to Info, Warn, etc. appear as top-level JSON keys:
p.Logger.Info("request completed",
"method", "POST",
"path", "/api/v1/deploy",
"status", 201,
"duration_ms", 42,
)
{"time":"...","level":"INFO","msg":"request completed","method":"POST","path":"/api/v1/deploy","status":201,"duration_ms":42}
Using OpenTelemetry¶
Replace slog.NewJSONHandler with an OTEL handler:
import "go.opentelemetry.io/contrib/bridges/otelslog"
otelHandler := otelslog.NewHandler("mytool",
otelslog.WithLoggerProvider(loggerProvider),
)
l = logger.NewSlog(otelHandler)
The rest of your code is unchanged โ all calls to p.Logger.Info(...) etc. flow through to the OTEL exporter.
Bridging to Third-Party Libraries¶
Some libraries require a *slog.Logger directly. Use l.Handler() to get the underlying handler:
slogLogger := slog.New(p.Logger.Handler())
// Pass to libraries that need *slog.Logger
grpcserver.SetLogger(slogLogger)
someSDK.WithLogger(slogLogger)
Contextual Fields¶
Add fields that appear on every subsequent log call from a given logger:
// Request-scoped logger (create per-request)
reqLogger := p.Logger.With(
"request_id", requestID,
"user", userID,
"service", "api",
)
reqLogger.Info("processing")
reqLogger.Warn("validation failed", "field", "email")
{"level":"INFO","msg":"processing","request_id":"abc123","user":"matt","service":"api"}
{"level":"WARN","msg":"validation failed","request_id":"abc123","user":"matt","service":"api","field":"email"}
Differences from the Charm Backend¶
| Behaviour | NewCharm |
NewSlog |
|---|---|---|
SetFormatter(JSONFormatter) |
Switches to JSON | No-op (format set at construction) |
Print(msg) |
Unlevelled output (no level prefix) | Emits at INFO level |
WithPrefix(prefix) |
Adds [prefix] visually |
Adds "prefix": "..." JSON field |
| Timestamp | Configurable via WithTimestamp |
Controlled by slog.HandlerOptions |
Testing¶
Tests should always use logger.NewNoop() โ it discards all output with zero allocations:
To assert specific log calls in tests, use the generated mock:
import mock_logger "gitlab.com/phpboyscout/go-tool-base/mocks/pkg/logger"
ml := mock_logger.NewMockLogger(t)
ml.EXPECT().Info("server started", "addr", ":8080").Once()
Related Documentation¶
- Logger component โ all backends,
CharmOptionfunctions,Handler()interop - Logging concepts โ when to use each backend