// 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)}
}