// Package observability hosts the cross-cutting steward services that wire // structured logging and Prometheus metrics into the lethe server. Both // services are constructed lazily by steward and have no dependencies on the // HTTP layer; HTTP middleware (Phase 5) reads from them. package observability import ( "context" "log/slog" "os" "go.bigb.es/auxilia/culpa" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/lethe/internal/config" ) // maskedKeys lists the attribute keys whose values must never appear in logs // in cleartext. The list is enforced uniformly across both formats: scribe's // WithMaskKeys for the tint handler, and a ReplaceAttr function for the // JSON handler. Keep this list as the single source of truth. var maskedKeys = []string{"password", "token", "authorization", "secret", "cookie"} // Unexported context-key types prevent accidental collisions with keys from // other packages. Helpers below are the only intended access path. type ( requestIDKey struct{} userKey struct{} ) // WithRequestID attaches a request id to ctx so the log handler can stamp // every record with it. Phase 5's request-id middleware calls this. func WithRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, requestIDKey{}, id) } // RequestIDFrom returns the request id previously attached with WithRequestID, // or the empty string if none is set. func RequestIDFrom(ctx context.Context) string { v, _ := ctx.Value(requestIDKey{}).(string) return v } // WithUser attaches the authenticated user to ctx. Phase 6's auth layer calls // this; the logger picks it up automatically. func WithUser(ctx context.Context, user string) context.Context { return context.WithValue(ctx, userKey{}, user) } // UserFrom returns the user attached with WithUser, or the empty string if // none is set. func UserFrom(ctx context.Context) string { v, _ := ctx.Value(userKey{}).(string) return v } // Logger is the steward-managed logging steward. The active *slog.Logger is // exposed via L for components that prefer explicit injection; everything // else can rely on slog.Default(), which Init points at L. type Logger struct { Cfg config.LoggingConfig `config:""` L *slog.Logger } // Init builds the configured handler (tint or json), wraps it in // contextHandler so request-id and user context fields end up in every // record, and installs the result as the package-default slog logger. func (l *Logger) Init(_ context.Context) error { level, err := parseLevel(l.Cfg.Level) if err != nil { return culpa.WithCode(err, "OBS_LOGGER_INIT") } var inner slog.Handler switch l.Cfg.Format { case "tint": opts := []scribe.Option{ scribe.WithWriter(os.Stderr), scribe.WithLevel(level), } // scribe's WithMaskKeys takes a variadic of strings. opts = append(opts, scribe.WithMaskKeys(maskedKeys...)) inner = scribe.NewTintHandler(opts...) case "json": inner = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ Level: level, ReplaceAttr: maskAttrJSON, }) default: // Config validation already restricts Format to {tint,json}; if we // reach here the validator was bypassed — treat it as a programming // error rather than papering over silently. return culpa.WithCode( culpa.New("unsupported log format"), "OBS_LOGGER_INIT", ) } logger := slog.New(&contextHandler{inner: inner}) l.L = logger slog.SetDefault(logger) return nil } // parseLevel maps the validated config string to a slog.Level. Returning an // error rather than defaulting silently — the validator should prevent any // unknown value from arriving here. func parseLevel(s string) (slog.Level, error) { switch s { case "debug": return slog.LevelDebug, nil case "info": return slog.LevelInfo, nil case "warn": return slog.LevelWarn, nil case "error": return slog.LevelError, nil default: return 0, culpa.New("unknown log level") } } // maskAttrJSON enforces the same mask-keys policy that scribe.WithMaskKeys // applies, but for the stdlib JSON handler. Match is case-insensitive on the // last group element to mirror scribe's behaviour. func maskAttrJSON(_ []string, a slog.Attr) slog.Attr { for _, k := range maskedKeys { if equalFold(a.Key, k) { return slog.String(a.Key, "***") } } return a } // equalFold is a tiny ASCII case-insensitive compare; we avoid importing // strings only for this. Both inputs are short attr keys. func equalFold(a, b string) bool { if len(a) != len(b) { return false } for i := 0; i < len(a); i++ { ca, cb := a[i], b[i] if ca >= 'A' && ca <= 'Z' { ca += 'a' - 'A' } if cb >= 'A' && cb <= 'Z' { cb += 'a' - 'A' } if ca != cb { return false } } return true } // contextHandler decorates an inner slog.Handler so every emitted record is // stamped with the request id and (if present) user pulled from the record's // context. WithAttrs/WithGroup delegate; the wrapped handler is rebuilt so // the chain remains intact. type contextHandler struct { inner slog.Handler } // Enabled delegates to the inner handler. func (h *contextHandler) Enabled(ctx context.Context, lvl slog.Level) bool { return h.inner.Enabled(ctx, lvl) } // Handle adds request_id and user attributes (when present in ctx) before // passing the record through to the inner handler. func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error { if id := RequestIDFrom(ctx); id != "" { r.AddAttrs(slog.String("request_id", id)) } if u := UserFrom(ctx); u != "" { r.AddAttrs(slog.String("user", u)) } return h.inner.Handle(ctx, r) } // WithAttrs returns a new contextHandler whose inner handler has the supplied // attributes pre-bound. func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return &contextHandler{inner: h.inner.WithAttrs(attrs)} } // WithGroup returns a new contextHandler scoped to the named group. func (h *contextHandler) WithGroup(name string) slog.Handler { return &contextHandler{inner: h.inner.WithGroup(name)} }