telemetry

Logolás

Standard log/slog, komponálva, és egy Monolog-formátumú dev konzol, PHP-stílusú stack trace-ekkel.

Go-ban az első hideg zuhany gyakran a logolás: a nyers log csomag egysoros, kontextus nélküli kimenete, vagy egy strukturált logger key=value fala, amiben egy stack trace egyetlen escape-elt stringgé lapul.

A kit válasza két rétegű. Az alap a standard log/slog: az alkalmazáskód kizárólag ezt importálja, semmi kit-specifikusat. Fölé a logx csomag rak kompozíciót, és egy Monolog-formátumú konzol-handlert (logx/monolog), ami dev módban pontosan úgy néz ki, mint amit PHP-ból ismersz: [dátum] channel.SZINT: üzenet {context}, alatta PHP-stílusú, teljes útvonalú stack trace.

Így néz ki

Állítsd be a LOG_FORMAT=monolog-ot (dev módban ezt érdemes), és a shop konzolja így ír:

[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

És amikor a PlaceOrder a kanonikus hibánkba, az ErrOutOfStock-ba fut:

[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}

Ami ebben a pár sorban dolgozik:

  • A channel a service neve (OTEL_SERVICE_NAME, üresen app), több service logját egymás mellett futtatva is látod, ki beszél. A WARN szint Monolog-hűen WARNING-ként jelenik meg.
  • A hiba fejléce a errs-kóddal kezdődik (shop_out_of_stock: ...), így a hibamód a JSON-rész elolvasása nélkül azonosítható; a kód a JSON contextben is ott van.
  • A stack trace teljes, abszolút útvonalakkal: a legtöbb terminál és editor kattinthatóvá teszi. A frame-ek a hiba keletkezési pontjától felfelé sorakoznak; a dependency-kből (Go module cache-ből) jövő frame-ek halványítva jelennek meg, a runtime-belépési frame-eket a {main} zárósor helyettesíti.
  • A hiba-attr nem duplázódik: a slog.Any("error", err) értéke a fejlécbe és a stack-blokkba kerül, a JSON contextből kimarad (a httperr duplikált cause attr-jával együtt).
  • Szín csak terminálon: a NO_COLOR-t és a TERM=dumb-ot tiszteletben tartja, pipe-olt kimenetben nincs escape-szemét.
  • Egy WARN/ERROR rekord errs stack nélkül is kap lokációt: a hívási helye kerül a sor végére in file:line formában.

A fenti hibát a httperr error handler logolta (request failed, method/path/status attr-okkal). Neked csak a hibát kellett visszaadni a service-ből. A stacket nem itt fogták el: az errs hiba születésekor rögzítette, és a wrap-lánc bármely pontjáról ugyanaz a teljes stack olvasható ki.

Ez kizárólag konzol-formátum. Az OTLP log-bridge és a Sentry handler ugyanazt a strukturált rekordot kapja, mint eddig: a LOG_FORMAT csak azt dönti el, mit látsz a terminálban.

Konfiguráció

A logx.Config a telemetry.Config Log mezője, a változók prefix nélkül, a top-szinten töltődnek:

VáltozóDefaultJelentés
LOG_LEVELINFOa default logger minimális szintje (minden sinkre érvényes)
LOG_STDOUTfalseprod módban is írjon konzolra (JSON); hagyd kikapcsolva, ha a collector a konténer-kimenetet szedi, különben duplán ingesztálsz
LOG_FORMATüres (auto)a konzol-formátum: üresen szöveg dev módban / JSON prodban; monolog → Monolog-stílusú sorok, módtól függetlenül

Ismeretlen LOG_FORMAT értékre a Config.Validate() (és így a boot) hibával áll le, nem csendben esik vissza.

A kompozíció: Fanout és LevelFilter

Monologban a logolást channelökből és stackekből raktad össze (stack driver, több channel egy alá fogva, level küszöbökkel). A logx ugyanezt a gondolatot adja vissza: csak nem YAML-ben, hanem három kis slog.Handler-kombinátorral:

func ConsoleHandler(w io.Writer, cfg Config, dev bool, channel string) slog.Handler
func Fanout(handlers ...slog.Handler) slog.Handler          // ~ Monolog stack driver
func LevelFilter(min slog.Level, next slog.Handler) slog.Handler // ~ channel-szintű level küszöb

A telemetry.Setup pontosan így építi a default loggert:

slog.SetDefault(slog.New(
    logx.LevelFilter(cfg.Log.Level, logx.Fanout(
        logx.ConsoleHandler(os.Stdout, cfg.Log, dev, serviceName), // dev vagy LOG_STDOUT esetén
        otelHandle.SlogHandler(),   // OTLP bridge, prodban
        sentryHandle.SlogHandler(), // Sentry capture, ha a DSN be van állítva
    )),
))

A LevelFilter azért ül legkívül, mert nem minden leaf-handler szűr magától: az OTLP bridge például mindent továbbít, amit kap. A Fanout minden handlernek a rekord saját klónját adja, és az Enabled-et handlerenkénti gate-ként tiszteli: egyetlen handler esetén allokáció nélkül, önmagaként tér vissza.

Chassis-on belül ezt sosem írod meg: a server.Run / worker.Run / realtime.Run megkapja készen. Közvetlenül akkor nyúlsz hozzá, ha egy nem-chassis binárisban (CLI, migrátor) ugyanezt a konzol-élményt akarod, vagy egy plusz sinket (pl. fájl-handlert) fűznél a fanoutba.

logx/monolog közelről

A formátum-driver önálló csomag, egyetlen konstruktorral:

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

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

A LOG_FORMAT=monolog esetén a logx.ConsoleHandler ezt adja vissza, de bárhova bekötheted, ahol egy slog.Handler elvárt. Amit érdemes tudni róla:

  • slog-natív. A With(...) attr-ok és a WithGroup(...) csoportok helyesen, beágyazott JSON-ként jelennek meg a context-részben ("req":{"status":200}).
  • A stackek forrása az errs. A handler a rekord első hiba-értékű attr-ját emeli ki; ha az (vagy a wrap-láncában bármi) errs hiba, az errs.FullFrames teljes útvonalú stackje renderelődik. Egy sima errors.New hibánál nincs stack-blokk: helyette a log hívási helye kerül a sorba.
  • Párhuzamos futásban is atomi sorok: a handler klónjai közös mutexen írnak, torn write nincs.

Strukturált hibák a logban: errs nélkül semmi ilyen nincs

A Monolog-formátum a stack trace-t az errs hibákból nyeri, és ez nem konzol-privilégium: az *errs.Error implementálja a slog.LogValuer-t, így minden strukturált sinkben (JSON konzol, OTLP) a hiba csoportként bomlik ki:

"error": {
  "msg": "shop: place order: inventory: decrement: insufficient stock",
  "code": "shop_out_of_stock",
  "public": "Egy vagy több tétel nincs elegendő készleten.",
  "chain": [ { "msg": "shop: place order", "file": "service/orders.go", "line": 52, "..." : "" }, "" ],
  "stack": [ { "file": "repository/inventory.go", "line": 87, "..." : "" }, "" ]
}

Ugyanaz a hiba tehát dev konzolon PHP-stílusú stack trace, prod logban gépi feldolgozásra kész JSON, a Sentryben pedig kód szerint csoportosított issue, mindez egyetlen return err-ből.

Monolog-fogalmak megfelelői

Ha a config/logging.php felől érkezel, így képződnek le a fogalmak:

MonologgpsystemHol
channel nevea service neve (OTEL_SERVICE_NAME)a monolog-sor shop.INFO része
stack driver (több channel egyben)logx.Fanouta telemetry.Setup építi
channel level küszöbLOG_LEVEL + logx.LevelFilterenv
single / daily file drivernincs (stdout az egyetlen cél)lásd lentebb
formatter (LineFormatter, JSON)LOG_FORMAT (auto / monolog)env
Log::info('msg', ['ctx' => ...])slog.InfoContext(ctx, "msg", slog.Any("ctx", ...))app-kód
Log::withContext([...])logger := slog.Default().With(...)app-kód
Sentry / Slack channel a stackbena fanout OTLP- és Sentry-ágaautomatikus

Egy csavar van, ami Monologban nem létezett: a context a trace-t is hordozza. A slog.InfoContext ctx-e nemcsak attr-forrás: az köti a logsort a trace-hez és a Sentry breadcrumb-hubhoz.

Hova lettek a fájl-logok?

Sehova, és ez szándékos. Nincs storage/logs/shop.log: a kit a 12-factor elvet követi, a logok eseményfolyamként a stdout-ra mennek, a gyűjtés-forgatás-őrzés pedig a platform dolga. Konténerben a runtime úgyis elkapja a stdout-ot; a collectorod (vagy LOG_STDOUT + a konténer-logok) viszik tovább. Prodban jellemzően nem is a konzol a fő csatorna, hanem az OTLP log-bridge: a logok ugyanabba a pipeline-ba érkeznek, mint a trace-ek és metrikák, trace ID-val összekötve.

Ha lokálisan mégis fájlba akarod fogni: go run ./cmd/shop 2>&1 | tee shop.log: a monolog-handler pipe-ban automatikusan színek nélkül ír. (Dev módban a kérésenkénti access-log sorokat nem a slog, hanem a router saját request loggere írja (chimiddleware.Logger), ezek formátuma ezért nem követi a LOG_FORMAT-ot.)

Hogyan logolj a service-ekben

Két szabály van, és mindkettő a context.Context-ről szól:

func (s *OrderService) PlaceOrder(ctx context.Context, in PlaceOrder) error {
    // 1. Mindig a *Context változatot, a kérés ctx-ével:
    slog.InfoContext(ctx, "order placed",
        slog.String("order_id", order.ID),
        slog.Int("total", order.Total))
    // ...
}
  1. slog.InfoContext / WarnContext / ErrorContext, sosem slog.Info. A ctx hordozza az aktív trace-t: az otelslog bridge ebből fűzi a trace/span ID-t a rekordhoz, a Sentry handler ebből linkeli a hibát a trace waterfallhoz, és a breadcrumb-ok is a kérés hubjára kerülnek. Ctx nélküli log = korrelálatlan log.
  2. Hibát slog.Any("error", err)-rel adj át, ne err.Error() stringgel: így marad meg a strukturált lánc, a stack és a kód minden sink számára.

Loggert injektálni, konstruálni, DI-ba kötni nem kell: a chassis a slog.Default()-ot állítja be, a csomag-szintű slog.*Context hívások pont jók. Az ERROR szint pedig nem „hangos log": az egyben Sentry event is: 5xx-et a kit logol helyetted, te akkor logolj ERROR-t, ha valami tényleg incidens.

A várható, kliens-hibás kimeneteket (404, 409, validáció) nem logolod és nem is kell: egy státusz-mappelt errs hiba 4xx-ként megy ki, request failed sor és Sentry event nélkül.
Copyright © 2026