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¶
A plain *slog.Logger from NewSlog owns its level through its handler, so set
the level at construction (slog.HandlerOptions{Level: …} above). To keep the
level adjustable at runtime, build the handler at its most permissive level and
wrap it with logger.NewLevelGate, then mutate the shared *slog.LevelVar:
levelVar := new(slog.LevelVar)
levelVar.Set(slog.LevelInfo)
base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
l := logger.NewSlog(logger.NewLevelGate(base, levelVar))
// Later — e.g. after config loads in PersistentPreRunE:
if lvl, err := logger.ParseLevel(os.Getenv("LOG_LEVEL")); err == nil {
levelVar.Set(mapToSlogLevel(lvl)) // debug/info/warn/error → slog.Level*
}
GTB's own root command drives this for Props.Logger via the
logger.SetLevel(log, slog.Level) helper — it takes effect on the default
Charm-backed logger (which implements Leveller) and is a harmless no-op on a
plain *slog.Logger.
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¶
Both constructors produce a logger.Logger with the same *slog.Logger method
set — the differences are in construction and runtime capabilities:
| Behaviour | NewCharm |
NewSlog |
|---|---|---|
logger.SetFormatter(l, JSONFormatter) |
Switches to JSON (implements Reformatter) |
No-op, returns false (format set by handler) |
logger.SetLevel(l, slog.LevelDebug) |
Applied (implements Leveller) |
No-op, returns false (wrap with NewLevelGate for runtime control) |
With("component", "svc") |
Styled component=svc |
"component": "svc" 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