diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 2be6e671c..31848b7c4 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -171,7 +171,9 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler { logLevel = slog.LevelInfo } - otelsetup.SetDefaultJSONLogger(logLevel) + // Adjust the level only. Reinstalling the default logger here would drop the OTLP + // bridge Init fanned on at startup, blinding the sink to every line after this. + otelsetup.SetLevel(logLevel) if gw.RateLimiter == nil { gw.RateLimiter = NewAuthRateLimiter() diff --git a/spinifex/otelsetup/slog.go b/spinifex/otelsetup/slog.go index 7b2589991..6752f0134 100644 --- a/spinifex/otelsetup/slog.go +++ b/spinifex/otelsetup/slog.go @@ -9,14 +9,28 @@ import ( "go.opentelemetry.io/otel/trace" ) +// defaultLevel backs the stdout handler's level. Held in a LevelVar so verbosity can +// change without reinstalling the handler, which would discard the OTLP bridge Init +// fans onto whatever default is installed at the time. +var defaultLevel = new(slog.LevelVar) + // SetDefaultJSONLogger installs the process-wide slog default: JSON to // stdout (journald) at the given level, with trace_id/span_id stamping. +// Call once, before Init; use SetLevel to change verbosity afterwards. func SetDefaultJSONLogger(level slog.Level) { + defaultLevel.Set(level) slog.SetDefault(slog.New(NewSlogHandler(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: level, + Level: defaultLevel, })))) } +// SetLevel changes the stdout handler's level in place, leaving the handler chain +// intact. Callers that only need to adjust verbosity must use this: calling +// SetDefaultJSONLogger again replaces the default and silently unbolts OTLP export. +func SetLevel(level slog.Level) { + defaultLevel.Set(level) +} + var _ slog.Handler = (*traceHandler)(nil) // traceHandler stamps trace_id/span_id from the record's context onto every diff --git a/spinifex/otelsetup/slog_level_test.go b/spinifex/otelsetup/slog_level_test.go new file mode 100644 index 000000000..03360461f --- /dev/null +++ b/spinifex/otelsetup/slog_level_test.go @@ -0,0 +1,66 @@ +package otelsetup + +import ( + "bytes" + "context" + "log/slog" + "testing" +) + +// recordingHandler stands in for the OTLP bridge: it only needs to prove it still +// receives records after a level change. +type recordingHandler struct{ msgs *[]string } + +func (recordingHandler) Enabled(context.Context, slog.Level) bool { return true } +func (h recordingHandler) Handle(_ context.Context, r slog.Record) error { + *h.msgs = append(*h.msgs, r.Message) + return nil +} +func (h recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h recordingHandler) WithGroup(string) slog.Handler { return h } + +// A gateway that reinstalled the default logger to change verbosity silently detached +// the OTLP bridge, so only startup lines ever reached the sink. +func TestSetLevelKeepsFanoutAttached(t *testing.T) { + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + + var got []string + SetDefaultJSONLogger(slog.LevelInfo) + addFanoutHandler(recordingHandler{msgs: &got}) + + SetLevel(slog.LevelDebug) + slog.Info("after level change") + + if len(got) != 1 || got[0] != "after level change" { + t.Fatalf("fanout handler lost after SetLevel, got %v", got) + } + + // The hazard SetLevel exists to avoid: reinstalling the default drops the fanout, + // so nothing logged afterwards reaches the bridge. + SetDefaultJSONLogger(slog.LevelInfo) + slog.Info("after reinstall") + if len(got) != 1 { + t.Fatalf("expected reinstall to detach the fanout, got %v", got) + } +} + +func TestSetLevelChangesVerbosityInPlace(t *testing.T) { + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + + var buf bytes.Buffer + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: defaultLevel}))) + + SetLevel(slog.LevelError) + slog.Info("suppressed") + if buf.Len() != 0 { + t.Fatalf("info logged at error level: %s", buf.String()) + } + + SetLevel(slog.LevelDebug) + slog.Debug("emitted") + if !bytes.Contains(buf.Bytes(), []byte("emitted")) { + t.Fatalf("debug not logged after level lowered: %s", buf.String()) + } +}