Logging
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 toapp), so running several services' logs side by side, you can still see who is talking. TheWARNlevel renders Monolog-faithfully asWARNING. - The error headline leads with the
errscode (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 withhttperr's duplicatecauseattr). - Color only on a terminal:
NO_COLORandTERM=dumbare honored; piped output carries no escape garbage. - A
WARN/ERRORrecord without anerrsstack still gets a location: its call site is appended asin 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.
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:
| Variable | Default | Meaning |
|---|---|---|
LOG_LEVEL | INFO | the default logger's minimum level (applies to every sink) |
LOG_STDOUT | false | also write to the console in production mode (JSON); leave off when a collector scrapes container output, or you ingest twice |
LOG_FORMAT | empty (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 andWithGroup(...)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 anerrserror, its full-path stack fromerrs.FullFramesis rendered. A plainerrors.Newerror 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:
| Monolog | gpsystem | Where |
|---|---|---|
| channel name | the service name (OTEL_SERVICE_NAME) | the shop.INFO part of the monolog line |
stack driver (several channels in one) | logx.Fanout | built by telemetry.Setup |
a channel's level threshold | LOG_LEVEL + logx.LevelFilter | env |
single / daily file driver | none (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 stack | the fanout's OTLP and Sentry branches | automatic |
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))
// ...
}
slog.InfoContext/WarnContext/ErrorContext, neverslog.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.- Pass errors with
slog.Any("error", err), not as anerr.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.
errs error goes out as a 4xx, with no request failed line and no Sentry event.