Monolog formatter
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. logx/monolog is logx's answer for development: a console handler that, set as the active format, looks exactly like what you know from PHP.
What it looks like
Set Format: "monolog" on logx.Config (or LOG_FORMAT=monolog, if logx.Config is loaded from the environment) and the 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 a wrapped errs error reaches the logger:
[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/orders.go(41): shop.(*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 names the record's source (a service name, typically), so running several services' logs side by side, you can still see who is talking. The
WARNlevel 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. - 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.
logx/monolog up close
The format driver is a standalone package with a single constructor:
import "github.com/gp-system/logx/monolog"
func NewHandler(w io.Writer, level slog.Level, channel string) *Handler
logx.ConsoleHandler returns this when Config.Format is "monolog", but you can wire it anywhere a slog.Handler is expected, with or without the rest of logx. 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 stack traces above are mined from errs errors, and that is not a console privilege: *errs.Error implements slog.LogValuer, so in any structured slog.Handler, not only logx/monolog, 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, and machine-ready JSON in any other handler you fan it out to (the framework's OTel log bridge and Sentry handler among them, see Telemetry: Logging), all from a single return err.
Where did the file logs go?
Nowhere, deliberately: there is no single/daily file driver here, logx/monolog writes to whatever io.Writer you give NewHandler, typically stdout. This follows the 12-factor principle: logs go to stdout as an event stream, and collection, rotation and retention are the platform's job, not the logger's. In a container the runtime captures stdout anyway; a collector, or a plain shell redirect, takes it from there.
If you still want a local file: go run ./cmd/myapp 2>&1 | tee myapp.log; in a pipe the handler automatically writes without colors.
Monolog concepts, mapped
If you arrive from config/logging.php, this is how the concepts map:
| Monolog | gpsystem | Where |
|---|---|---|
| channel name | whatever channel you pass NewHandler (the framework uses OTEL_SERVICE_NAME) | the shop.INFO part of the monolog line |
stack driver (several channels in one) | logx.Fanout | logx: Overview |
a channel's level threshold | logx.LevelFilter | logx: Overview |
single / daily file driver | none (stdout is the only destination) | see above |
formatter (LineFormatter, JSON) | logx.Config.Format (auto / monolog) | logx: Overview |
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 | another handler added to the fanout | your own wiring, or the framework's fanout, see Telemetry: Logging |
Related pages
- logx: Overview: install,
Config, and theConsoleHandler/LevelFilter/Fanoutcomposition this formatter plugs into. - errs: Overview: the error type this formatter's stack traces come from.
- Telemetry: Logging: how the framework fans this handler out alongside the OTel log bridge and the Sentry capture handler.