Problem
Every line the server writes goes through the standard library text logger. 227 log.Printf / log.Println calls, spread like this:
60 internal/pipeline
39 internal/stt
24 internal/tts
19 internal/llm
17 internal/realtime
14 internal/plugin
14 internal/peer
14 main.go
13 internal/session
12 internal/signaling
The convention is a hand-written bracket prefix, and it is inconsistent about carrying the session:
log.Printf("[session:%s] remote track ready, starting pipeline (resumed=%v)", s.ID, resumed)
log.Printf("[summary] rolling summary timed out after %s — will retry on a later turn", ...)
log.Printf("[manager] could not issue resume token for %s: %v", s.ID, err)
[summary] is the one that hurts. With several calls in flight — which is the entire point of a media server — the rolling-summary, thinking-sound, RAG-prefetch and barge-in lines interleave with no way to attribute them. Debugging one bad call means grepping a timestamp window and guessing.
Downstream, none of this is queryable. Cloud Logging, Loki and CloudWatch all index JSON fields and all treat this output as an opaque string, so "show me every turn for session X" is not a query anyone can write.
Proposed change
Migrate to log/slog, which is in the standard library — no new dependency.
Handler. JSON by default in production, text when the output is a TTY or when logging.format = "text", because losing readable local output is how a migration like this gets reverted. Level from logging.level (debug/info/warn/error), defaulting to info.
Session-scoped logger. The important half. session.Session holds a *slog.Logger built once with slog.With("session_id", s.ID), and passes it into pipeline.New alongside the config it already receives. Everything downstream logs through that logger and every line carries the session for free. The component prefix becomes an attribute rather than a string prefix: logger.With("component", "summary").
Migrate incrementally. Do not attempt all 227 in one PR. A reasonable order, each mergeable on its own:
main.go + handler setup + config plumbing
internal/session and internal/peer (establishes the session logger)
internal/pipeline (the 60, and the ones that most need attribution)
- providers:
internal/stt, internal/tts, internal/llm, internal/realtime
- the rest, plus a
go vet-adjacent check that nothing new lands on log.
Route the default log package at the slog handler during the migration (slog.SetDefault + log.SetOutput) so un-migrated call sites still land in the same stream instead of bypassing the format.
Secrets. Provider adapters log request context. Adding structured fields makes it easier to log a whole config struct by accident. Nothing that could hold an API key gets logged as a value; docs/configuration.md already establishes the "by name, never by value" rule for env overrides and this should follow it.
Acceptance criteria
Pointers
internal/session/session.go — where the per-session logger is built; pipeline.New call ~L167
internal/pipeline/rolling_summary.go — the [summary] lines that motivate this
internal/session/manager.go:62 — logs a session ID today, by luck of the format string
main.go — handler construction and slog.SetDefault
docs/configuration.md — secrets-by-name convention to follow
Problem
Every line the server writes goes through the standard library text logger. 227
log.Printf/log.Printlncalls, spread like this:The convention is a hand-written bracket prefix, and it is inconsistent about carrying the session:
[summary]is the one that hurts. With several calls in flight — which is the entire point of a media server — the rolling-summary, thinking-sound, RAG-prefetch and barge-in lines interleave with no way to attribute them. Debugging one bad call means grepping a timestamp window and guessing.Downstream, none of this is queryable. Cloud Logging, Loki and CloudWatch all index JSON fields and all treat this output as an opaque string, so "show me every turn for session X" is not a query anyone can write.
Proposed change
Migrate to
log/slog, which is in the standard library — no new dependency.Handler. JSON by default in production, text when the output is a TTY or when
logging.format = "text", because losing readable local output is how a migration like this gets reverted. Level fromlogging.level(debug/info/warn/error), defaulting toinfo.Session-scoped logger. The important half.
session.Sessionholds a*slog.Loggerbuilt once withslog.With("session_id", s.ID), and passes it intopipeline.Newalongside the config it already receives. Everything downstream logs through that logger and every line carries the session for free. The component prefix becomes an attribute rather than a string prefix:logger.With("component", "summary").Migrate incrementally. Do not attempt all 227 in one PR. A reasonable order, each mergeable on its own:
main.go+ handler setup + config plumbinginternal/sessionandinternal/peer(establishes the session logger)internal/pipeline(the 60, and the ones that most need attribution)internal/stt,internal/tts,internal/llm,internal/realtimego vet-adjacent check that nothing new lands onlog.Route the default
logpackage at the slog handler during the migration (slog.SetDefault+log.SetOutput) so un-migrated call sites still land in the same stream instead of bypassing the format.Secrets. Provider adapters log request context. Adding structured fields makes it easier to log a whole config struct by accident. Nothing that could hold an API key gets logged as a value;
docs/configuration.mdalready establishes the "by name, never by value" rule for env overrides and this should follow it.Acceptance criteria
[logging] levelandformatinconfig.toml.exampleanddocs/configuration.md.session_idpresent on every line emitted inside a session.log.Printfcount in the repo trends to zero; the final PR in the series adds a check that keeps it there.session_id.Pointers
internal/session/session.go— where the per-session logger is built;pipeline.Newcall ~L167internal/pipeline/rolling_summary.go— the[summary]lines that motivate thisinternal/session/manager.go:62— logs a session ID today, by luck of the format stringmain.go— handler construction andslog.SetDefaultdocs/configuration.md— secrets-by-name convention to follow