Skip to content

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:

import "golang.org/x/term"

func isTerminal(f *os.File) bool {
    return term.IsTerminal(int(f.Fd()))
}

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:

p := &props.Props{
    Logger: logger.NewNoop(),
}

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()