~bigbes/lethe

ref: da1827c5192d2784fcecf01578b5f307ea15a8c1 lethe/internal/platform/observability/logger.go -rw-r--r-- 5.9 KiB
da1827c5 — Eugene Blikh docs: record collector endpoint and outbox checks 24 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
// 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)}
}