Observability

Logging

Standard log/slog, composed, and a Monolog-format dev console with PHP-style stack traces.

In Go, the first cold shower is often logging: the raw log package's one-line, context-free output, or a structured logger's wall of key=value where a stack trace collapses into a single escaped string.

The kit's answer has two layers. The base is standard log/slog: application code imports only that, nothing kit-specific. On top, the logx package adds composition, and a Monolog-format console handler (logx/monolog) that in dev mode looks exactly like what you know from PHP: [date] channel.LEVEL: message {context}, followed by a PHP-style stack trace with full file paths.

What it looks like

Set LOG_FORMAT=monolog (recommended in dev mode) and the shop's console reads like this:

[2026-07-15 10:23:44] shop.INFO: order placed {"order_id":"o_8412","total":12990}
[2026-07-15 10:23:45] shop.WARNING: payment provider slow, retrying {"attempt":2,"provider":"stripe"} in /home/you/shop/internal/modules/shop/surfaces/api/service/payments.go:114

And when PlaceOrder runs into our canonical error, ErrOutOfStock:

[2026-07-15 10:23:46] shop.ERROR: request failed: shop_out_of_stock: shop: place order: inventory: decrement: insufficient stock {"code":"shop_out_of_stock","method":"POST","path":"/api/v1/shop/orders","status":500}
Stack trace:
#0 /home/you/shop/internal/modules/shop/repository/inventory.go(87): repository.(*InventoryRepository).Decrement()
#1 /home/you/shop/internal/modules/shop/core/orders.go(41): core.(*Service).PlaceOrder()
#2 /home/you/shop/internal/modules/shop/surfaces/api/service/orders.go(52): service.(*OrderService).PlaceOrder()
#3 /home/you/shop/internal/modules/shop/surfaces/api/http/handlers.go(61): http.(*Handler).PlaceOrder()
#4 {main}

What's at work in these few lines:

  • The channel is the service name (OTEL_SERVICE_NAME, falling back to app), so running several services' logs side by side, you can still see who is talking. The WARN level renders Monolog-faithfully as WARNING.
  • The error headline leads with the errs code (shop_out_of_stock: ...), so the failure mode is identifiable without reading the JSON tail; the code also appears in the JSON context.
  • The stack trace is complete, with absolute paths: most terminals and editors make them clickable. Frames run from the error's point of origin upward; frames from dependencies (the Go module cache) are dimmed, and the runtime entry frames are replaced by the terminal {main} line.
  • The error attr is not duplicated: the value of slog.Any("error", err) goes into the headline and the stack block and is excluded from the JSON context (along with httperr's duplicate cause attr).
  • Color only on a terminal: NO_COLOR and TERM=dumb are honored; piped output carries no escape garbage.
  • A WARN/ERROR record without an errs stack still gets a location: its call site is appended as in file:line.

The error above was logged by the httperr error handler (request failed, with method/path/status attrs). All you did was return the error from the service. The stack was not captured there: the errs error recorded it at birth, and the same full stack is readable from any point of the wrap chain.

This is strictly a console format. The OTLP log bridge and the Sentry handler receive the same structured record as before: LOG_FORMAT only decides what you see in the terminal.

Configuration

logx.Config is the Log field of telemetry.Config, and the variables load unprefixed, at the top level:

VariableDefaultMeaning
LOG_LEVELINFOthe default logger's minimum level (applies to every sink)
LOG_STDOUTfalsealso write to the console in production mode (JSON); leave off when a collector scrapes container output, or you ingest twice
LOG_FORMATempty (auto)the console format: empty means text in dev / JSON in prod; monolog → Monolog-style lines regardless of mode

An unknown LOG_FORMAT value fails Config.Validate() (and therefore boot) with an error instead of silently falling back.

The composition: Fanout and LevelFilter

In Monolog you assembled logging from channels and stacks (the stack driver, several channels grouped under one, with level thresholds). logx gives you the same idea back: just not in YAML, but as three small slog.Handler combinators:

func ConsoleHandler(w io.Writer, cfg Config, dev bool, channel string) slog.Handler
func Fanout(handlers ...slog.Handler) slog.Handler          // ~ Monolog's stack driver
func LevelFilter(min slog.Level, next slog.Handler) slog.Handler // ~ a channel-level threshold

telemetry.Setup builds the default logger exactly this way:

slog.SetDefault(slog.New(
    logx.LevelFilter(cfg.Log.Level, logx.Fanout(
        logx.ConsoleHandler(os.Stdout, cfg.Log, dev, serviceName), // in dev, or with LOG_STDOUT
        otelHandle.SlogHandler(),   // OTLP bridge, in prod
        sentryHandle.SlogHandler(), // Sentry capture, when the DSN is set
    )),
))

LevelFilter sits outermost because not every leaf handler filters on its own: the OTLP bridge, for instance, forwards every record it is given. Fanout hands each handler its own clone of the record and honors Enabled as a per-handler gate, and with a single handler it returns it as-is, allocation-free.

Inside a chassis you never write this: fiberx.Run / chix.Run / worker.Run get it for free. You reach for it directly when a non-chassis binary (CLI, migrator) wants the same console experience, or to weave an extra sink (say, a file handler) into the fanout.

logx/monolog up close

The format driver is a standalone package with a single constructor:

import "github.com/gp-system/gpsystem/logx/monolog"

func NewHandler(w io.Writer, level slog.Level, channel string) *Handler

With LOG_FORMAT=monolog, logx.ConsoleHandler returns this, but you can wire it anywhere a slog.Handler is expected. Worth knowing:

  • It is slog-native. With(...) attrs and WithGroup(...) groups render correctly, as nested JSON in the context part ("req":{"status":200}).
  • Stacks come from errs. The handler pulls out the record's first error-valued attr; if it (or anything in its wrap chain) is an errs error, its full-path stack from errs.FullFrames is rendered. A plain errors.New error gets no stack block: the log call site goes on the line instead.
  • Lines stay atomic under concurrency: the handler's clones share a mutex for writes; there are no torn lines.

Structured errors in logs: none of this without errs

The Monolog format mines its stack traces from errs errors, and that is not a console privilege: *errs.Error implements slog.LogValuer, so in every structured sink (JSON console, OTLP) the error unfolds as a group:

"error": {
  "msg": "shop: place order: inventory: decrement: insufficient stock",
  "code": "shop_out_of_stock",
  "public": "One or more items are out of stock.",
  "chain": [ { "msg": "shop: place order", "file": "service/orders.go", "line": 52, "..." : "" }, "" ],
  "stack": [ { "file": "repository/inventory.go", "line": 87, "..." : "" }, "" ]
}

So the same error is a PHP-style stack trace on the dev console, machine-ready JSON in the prod log, and a code-grouped issue in Sentry, all from a single return err.

Monolog concepts, mapped

If you arrive from config/logging.php, this is how the concepts map:

MonologgpsystemWhere
channel namethe service name (OTEL_SERVICE_NAME)the shop.INFO part of the monolog line
stack driver (several channels in one)logx.Fanoutbuilt by telemetry.Setup
a channel's level thresholdLOG_LEVEL + logx.LevelFilterenv
single / daily file drivernone (stdout is the only destination)see below
formatter (LineFormatter, JSON)LOG_FORMAT (auto / monolog)env
Log::info('msg', ['ctx' => ...])slog.InfoContext(ctx, "msg", slog.Any("ctx", ...))app code
Log::withContext([...])logger := slog.Default().With(...)app code
a Sentry / Slack channel in the stackthe fanout's OTLP and Sentry branchesautomatic

There is one twist that never existed in Monolog: the context also carries the trace. The ctx of slog.InfoContext is not just an attr source: it is what ties the log line to the trace and to the Sentry breadcrumb hub.

Where did the file logs go?

Nowhere, deliberately. There is no storage/logs/shop.log: the kit follows the 12-factor principle, logs go to stdout as an event stream, and collection, rotation and retention are the platform's job. In a container the runtime captures stdout anyway; your collector (or LOG_STDOUT + container logs) takes it from there. In prod, the main channel typically isn't even the console but the OTLP log bridge: logs arrive in the same pipeline as traces and metrics, tied together by trace IDs.

If you still want a local file: go run ./cmd/shop 2>&1 | tee shop.log: in a pipe the monolog handler automatically writes without colors. (In dev mode the per-request access-log lines come not from slog but from the engine's own request logger (the logger middleware on Fiber, chimiddleware.Logger on chi), which is why their format does not follow LOG_FORMAT.)

How to log in your services

There are two rules, and both are about context.Context:

func (s *OrderService) PlaceOrder(ctx context.Context, in PlaceOrder) error {
    // 1. Always the *Context variant, with the request ctx:
    slog.InfoContext(ctx, "order placed",
        slog.String("order_id", order.ID),
        slog.Int("total", order.Total))
    // ...
}
  1. slog.InfoContext / WarnContext / ErrorContext, never slog.Info. The ctx carries the active trace: the otelslog bridge attaches the trace/span ID to the record from it, the Sentry handler links the error to the trace waterfall through it, and breadcrumbs land on the request's hub through it. A ctx-less log is an uncorrelated log.
  2. Pass errors with slog.Any("error", err), not as an err.Error() string: that is what preserves the structured chain, the stack and the code for every sink.

There is no logger to inject, construct or wire into DI: the chassis sets slog.Default(), and the package-level slog.*Context calls are exactly right. And the ERROR level is not just a "loud log": it is also a Sentry event. The kit logs 5xx for you; you log ERROR when something truly is an incident.

Expected, client-fault outcomes (404, 409, validation) you neither log nor need to: a status-mapped errs error goes out as a 4xx, with no request failed line and no Sentry event.
Copyright © 2026