diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e96603d..07ab3fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,10 @@ jobs: - name: golangci-lint run: make lint - - name: Deterministic guard allocation ceilings - run: go test -count=1 -run '^TestPromptGuardAllocationCeilings$' ./pkg/promptguard + - name: Deterministic allocation ceilings + run: | + go test -count=1 -run '^TestPromptGuardAllocationCeilings$' ./pkg/promptguard + go test -count=1 -run '^TestLoggingAllocationCeilings$' ./pkg/logging - name: go test race run: go test -race -count=1 ./... diff --git a/pkg/logging/allocation_test.go b/pkg/logging/allocation_test.go new file mode 100644 index 0000000..d5abd09 --- /dev/null +++ b/pkg/logging/allocation_test.go @@ -0,0 +1,52 @@ +//go:build !race + +package logging + +import ( + "context" + "io" + "log/slog" + "testing" +) + +func TestLoggingAllocationCeilings(t *testing.T) { + logger := slog.New(newFormatHandler(slog.LevelInfo, io.Discard)) + ctx := WithRequestID(WithRuntime(context.Background(), "bot"), "req-1") + + tests := []struct { + name string + maxAllocs float64 + call func() + }{ + { + name: "log common path", + maxAllocs: 5, + call: func() { + Log(ctx, logger, slog.LevelInfo, "request.completed", "request completed", + slog.String("method", "GET"), slog.Int("status", 200)) + }, + }, + { + name: "log and wrap error", + maxAllocs: 12, + call: func() { + _ = LogAndWrapError(ctx, logger, "op", context.DeadlineExceeded, slog.String("a", "b")) + }, + }, + { + name: "log warn with error attrs", + maxAllocs: 12, + call: func() { + LogWarnWithErrorAttrs(ctx, logger, "event", "message", context.DeadlineExceeded, slog.String("a", "b")) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := testing.AllocsPerRun(200, test.call); got > test.maxAllocs { + t.Fatalf("%s allocs = %v, want <= %v", test.name, got, test.maxAllocs) + } + }) + } +} diff --git a/pkg/logging/context.go b/pkg/logging/context.go index 554a26a..3e33e7c 100644 --- a/pkg/logging/context.go +++ b/pkg/logging/context.go @@ -6,15 +6,22 @@ import ( "strings" ) -type contextKey string +type contextKey uint8 const ( - requestIDContextKey contextKey = "hololive.logging.request_id" - jobIDContextKey contextKey = "hololive.logging.job_id" - runtimeContextKey contextKey = "hololive.logging.runtime" - componentContextKey contextKey = "hololive.logging.component" + requestIDContextKey contextKey = iota + jobIDContextKey + runtimeContextKey + componentContextKey ) +type contextValues struct { + runtime string + component string + requestID string + jobID string +} + func WithRequestID(ctx context.Context, requestID string) context.Context { return withString(ctx, requestIDContextKey, requestID) } @@ -48,27 +55,76 @@ func componentFromContext(ctx context.Context) string { } func ContextAttrs(ctx context.Context) []slog.Attr { - if ctx == nil { + values := contextValuesFrom(ctx) + count := values.count() + if count == 0 { return nil } - attrs := make([]slog.Attr, 0, 4) - if value := runtimeFromContext(ctx); value != "" { - attrs = append(attrs, Runtime(value)) + return values.appendTo(make([]slog.Attr, 0, count)) +} + +func contextValuesFrom(ctx context.Context) contextValues { + if ctx == nil { + return contextValues{} + } + + return contextValues{ + runtime: runtimeFromContext(ctx), + component: componentFromContext(ctx), + requestID: requestIDFromContext(ctx), + jobID: jobIDFromContext(ctx), + } +} + +func (v contextValues) count() int { + count := 0 + if v.runtime != "" { + count++ } - if value := componentFromContext(ctx); value != "" { - attrs = append(attrs, componentAttr(value)) + if v.component != "" { + count++ } - if value := requestIDFromContext(ctx); value != "" { - attrs = append(attrs, RequestID(value)) + if v.requestID != "" { + count++ } - if value := jobIDFromContext(ctx); value != "" { - attrs = append(attrs, jobIDAttr(value)) + if v.jobID != "" { + count++ } + return count +} +func (v contextValues) appendTo(attrs []slog.Attr) []slog.Attr { + if v.runtime != "" { + attrs = append(attrs, Runtime(v.runtime)) + } + if v.component != "" { + attrs = append(attrs, componentAttr(v.component)) + } + if v.requestID != "" { + attrs = append(attrs, RequestID(v.requestID)) + } + if v.jobID != "" { + attrs = append(attrs, jobIDAttr(v.jobID)) + } return attrs } +func (v contextValues) addToRecord(record *slog.Record) { + if v.runtime != "" { + record.AddAttrs(Runtime(v.runtime)) + } + if v.component != "" { + record.AddAttrs(componentAttr(v.component)) + } + if v.requestID != "" { + record.AddAttrs(RequestID(v.requestID)) + } + if v.jobID != "" { + record.AddAttrs(jobIDAttr(v.jobID)) + } +} + func withString(ctx context.Context, key contextKey, value string) context.Context { if ctx == nil { ctx = context.Background() diff --git a/pkg/logging/format.go b/pkg/logging/format.go index 7cb6aea..2783612 100644 --- a/pkg/logging/format.go +++ b/pkg/logging/format.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "log/slog" + "strconv" "strings" ) @@ -38,16 +39,22 @@ func shortenSource(groups []string, attr slog.Attr) slog.Attr { if !ok { return attr } - return slog.Any(slog.SourceKey, &slog.Source{ - Function: source.Function, - File: lastPathSegments(source.File), - Line: source.Line, - }) + // PC 0 record는 빈 Source를 낳는다. 빈 Attr을 돌려줘야 slog이 통째로 생략한다. + // 평탄화 판본은 이 가드가 없으면 ":0"을 실어 생략을 되살리지 못한다. + if source.File == "" && source.Line == 0 { + return slog.Attr{} + } + + var buf [128]byte + out := append(buf[:0], lastPathSegments(source.File)...) + out = append(out, ':') + out = strconv.AppendInt(out, int64(source.Line), 10) + return slog.String(slog.SourceKey, string(out)) } // filepath.Join은 Clean 때문에 record마다 할당한다. 여기서는 substring slice로 충분하다. -// 빈 경로에 ""를 돌려주는 것이 load-bearing이다. filepath 판본은 "."를 만들어, PC 0 record가 -// 낳는 빈 Source를 slog이 생략하지 못하게 되살린다. +// 빈 경로에 ""를 돌려주는 것이 load-bearing이다. filepath 판본은 "."를 만들어, File만 +// 비어 있고 Line이 살아 있는 record에 ".:42" 같은 허위 경로를 남긴다. func lastPathSegments(path string) string { base := strings.LastIndexByte(path, '/') if base < 0 { diff --git a/pkg/logging/format_test.go b/pkg/logging/format_test.go index a5e67c5..4428d65 100644 --- a/pkg/logging/format_test.go +++ b/pkg/logging/format_test.go @@ -2,6 +2,7 @@ package logging import ( "bytes" + "context" "encoding/json" "io" "log/slog" @@ -10,6 +11,7 @@ import ( "reflect" "strings" "testing" + "time" ) const ( @@ -356,19 +358,35 @@ func TestJSONFormat_ShortensSourcePath(t *testing.T) { slog.New(newFormatHandler(slog.LevelInfo, &buf)).Info("format_probe_source") record := probeJSONRecord(t, "json/source", buf.String()) - source, ok := record[slog.SourceKey].(map[string]any) + source, ok := record[slog.SourceKey].(string) if !ok { - t.Fatalf("source is not an object: %v", record[slog.SourceKey]) + t.Fatalf("source is not a string: %v", record[slog.SourceKey]) + } + file, line, found := strings.Cut(source, ":") + if !found { + t.Fatalf("source = %q, want \"file:line\"", source) } - file, _ := source["file"].(string) if filepath.IsAbs(file) { - t.Fatalf("source.file is an absolute build path: %q", file) + t.Fatalf("source file is an absolute build path: %q", file) } if want := "logging/format_test.go"; file != want { - t.Fatalf("source.file = %q, want %q", file, want) + t.Fatalf("source file = %q, want %q", file, want) + } + if line == "" || line == "0" { + t.Fatalf("source line dropped: %q", source) } - if _, ok := source["line"]; !ok { - t.Fatalf("source.line dropped: %v", source) +} + +func TestJSONFormat_OmitsSourceForZeroPC(t *testing.T) { + var buf bytes.Buffer + handler := newFormatHandler(slog.LevelInfo, &buf) + if err := handler.Handle(context.Background(), slog.NewRecord(time.Now(), slog.LevelInfo, "format_probe_zero_pc", 0)); err != nil { + t.Fatalf("handle zero-PC record: %v", err) + } + + record := probeJSONRecord(t, "json/zero-pc", buf.String()) + if value, ok := record[slog.SourceKey]; ok { + t.Fatalf("PC 0 record carries a source attr: %v", value) } } diff --git a/pkg/logging/hotpath_test.go b/pkg/logging/hotpath_test.go new file mode 100644 index 0000000..4b6edd3 --- /dev/null +++ b/pkg/logging/hotpath_test.go @@ -0,0 +1,215 @@ +package logging + +import ( + "context" + "log/slog" + "runtime" + "strings" + "testing" +) + +type hotpathCaptureHandler struct { + enabledCalls int + handled int + record slog.Record +} + +func (h *hotpathCaptureHandler) Enabled(context.Context, slog.Level) bool { + h.enabledCalls++ + return true +} + +func (h *hotpathCaptureHandler) Handle(_ context.Context, record slog.Record) error { + h.handled++ + h.record = record.Clone() + return nil +} + +func (h *hotpathCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *hotpathCaptureHandler) WithGroup(string) slog.Handler { return h } + +type hotpathDiscardHandler struct{} + +func (hotpathDiscardHandler) Enabled(context.Context, slog.Level) bool { return true } +func (hotpathDiscardHandler) Handle(context.Context, slog.Record) error { return nil } +func (h hotpathDiscardHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h hotpathDiscardHandler) WithGroup(string) slog.Handler { return h } + +func TestLog_CommonPathZeroAlloc(t *testing.T) { + logger := slog.New(hotpathDiscardHandler{}) + ctx := WithRequestID(WithRuntime(context.Background(), "bot"), "req-1") + + got := testing.AllocsPerRun(1000, func() { + Log(ctx, logger, slog.LevelInfo, "request.completed", "request completed", + slog.String("method", "GET"), + slog.Int("status", 200), + ) + }) + if got != 0 { + t.Fatalf("Log common-path allocs = %v, want 0", got) + } +} + +func TestLog_CallsEnabledOnce(t *testing.T) { + handler := &hotpathCaptureHandler{} + logger := slog.New(handler) + + Info(context.Background(), logger, "event", "message") + + if handler.enabledCalls != 1 { + t.Fatalf("Enabled calls = %d, want 1", handler.enabledCalls) + } + if handler.handled != 1 { + t.Fatalf("Handle calls = %d, want 1", handler.handled) + } +} + +func TestLog_CapturesWrapperCaller(t *testing.T) { + handler := &hotpathCaptureHandler{} + logger := slog.New(handler) + + Info(context.Background(), logger, "event", "message") + + frames := runtime.CallersFrames([]uintptr{handler.record.PC}) + frame, _ := frames.Next() + if !strings.HasSuffix(frame.Function, ".TestLog_CapturesWrapperCaller") { + t.Fatalf("source function = %q, want test caller", frame.Function) + } + if !strings.HasSuffix(frame.File, "pkg/logging/hotpath_test.go") { + t.Fatalf("source file = %q, want hotpath_test.go", frame.File) + } +} + +func TestContextAttrs_EmptyZeroAlloc(t *testing.T) { + got := testing.AllocsPerRun(1000, func() { + if attrs := ContextAttrs(context.Background()); attrs != nil { + panic("empty context attrs must be nil") + } + }) + if got != 0 { + t.Fatalf("ContextAttrs(empty) allocs = %v, want 0", got) + } +} + +func TestBroadValueKeyNormalizesRawKeys(t *testing.T) { + if !isBroadValueKey(" KEY ") { + t.Fatal("raw broad-value key was not normalized") + } + if isBroadValueKey("api_key") { + t.Fatal("sensitive exact key must not be classified as a broad-value key") + } +} + +func TestSanitizeCleanGroup_ZeroAlloc(t *testing.T) { + attr := slog.Group("request", + slog.String("method", "GET"), + slog.String("path", "/api/users"), + slog.Int("status", 200), + ) + var ( + out slog.Attr + changed bool + ) + + got := testing.AllocsPerRun(1000, func() { + out, changed = sanitizeAttrChanged(attr) + }) + if got != 0 { + t.Fatalf("clean group sanitize allocs = %v, want 0", got) + } + if changed { + t.Fatal("clean group reported a change") + } + if !out.Equal(attr) { + t.Fatalf("clean group changed: got %v, want %v", out, attr) + } +} + +func TestSanitizeGroupCopyOnWrite_MasksNestedValue(t *testing.T) { + attr := slog.Group("request", + slog.String("method", "GET"), + slog.Group("headers", + slog.String("authorization", "Bearer secret"), + slog.String("accept", "application/json"), + ), + slog.Int("status", 200), + ) + + out, changed := sanitizeAttrChanged(attr) + if !changed { + t.Fatal("sensitive nested group reported no change") + } + + requestAttrs := out.Value.Group() + if len(requestAttrs) != 3 { + t.Fatalf("request attrs = %d, want 3", len(requestAttrs)) + } + headers := requestAttrs[1].Value.Group() + if len(headers) != 2 { + t.Fatalf("header attrs = %d, want 2", len(headers)) + } + if got := headers[0].Value.String(); got != redactedValue { + t.Fatalf("authorization = %q, want %q", got, redactedValue) + } + if got := headers[1].Value.String(); got != "application/json" { + t.Fatalf("accept = %q, want %q", got, "application/json") + } + + originalHeaders := attr.Value.Group()[1].Value.Group() + if got := originalHeaders[0].Value.String(); got != "Bearer secret" { + t.Fatalf("caller-owned group mutated: authorization = %q", got) + } +} + +func TestErrorHelpers_CaptureCallerSource(t *testing.T) { + cases := map[string]func(context.Context, *slog.Logger){ + "LogAndWrapError": func(ctx context.Context, logger *slog.Logger) { + _ = LogAndWrapError(ctx, logger, "op", context.DeadlineExceeded) + }, + "LogWarnWithErrorAttrs": func(ctx context.Context, logger *slog.Logger) { + LogWarnWithErrorAttrs(ctx, logger, "event", "message", context.DeadlineExceeded) + }, + } + + for name, call := range cases { + t.Run(name, func(t *testing.T) { + handler := &hotpathCaptureHandler{} + call(context.Background(), slog.New(handler)) + + frames := runtime.CallersFrames([]uintptr{handler.record.PC}) + frame, _ := frames.Next() + if !strings.HasSuffix(frame.File, "pkg/logging/hotpath_test.go") { + t.Fatalf("source file = %q, want the call site rather than the helper body", frame.File) + } + }) + } +} + +func BenchmarkLogCommonPath(b *testing.B) { + logger := slog.New(hotpathDiscardHandler{}) + ctx := WithRequestID(WithRuntime(context.Background(), "bot"), "req-1") + b.ReportAllocs() + for range b.N { + Log(ctx, logger, slog.LevelInfo, "request.completed", "request completed", + slog.String("method", "GET"), + slog.Int("status", 200), + ) + } +} + +func BenchmarkSanitizeCleanGroup(b *testing.B) { + attr := slog.Group("request", + slog.String("method", "GET"), + slog.String("path", "/api/users"), + slog.Int("status", 200), + ) + var out slog.Attr + + b.ReportAllocs() + for range b.N { + out, _ = sanitizeAttrChanged(attr) + } + if !out.Equal(attr) { + b.Fatal("clean group changed") + } +} diff --git a/pkg/logging/log.go b/pkg/logging/log.go index cbbb911..125e86e 100644 --- a/pkg/logging/log.go +++ b/pkg/logging/log.go @@ -3,26 +3,47 @@ package logging import ( "context" "log/slog" + "runtime" "strings" + "time" ) func Debug(ctx context.Context, logger *slog.Logger, event, message string, attrs ...slog.Attr) { - Log(ctx, logger, slog.LevelDebug, event, message, attrs...) + log(ctx, logger, slog.LevelDebug, event, message, attrs...) } func Info(ctx context.Context, logger *slog.Logger, event, message string, attrs ...slog.Attr) { - Log(ctx, logger, slog.LevelInfo, event, message, attrs...) + log(ctx, logger, slog.LevelInfo, event, message, attrs...) } func Warn(ctx context.Context, logger *slog.Logger, event, message string, attrs ...slog.Attr) { - Log(ctx, logger, slog.LevelWarn, event, message, attrs...) + log(ctx, logger, slog.LevelWarn, event, message, attrs...) } func Error(ctx context.Context, logger *slog.Logger, event, message string, attrs ...slog.Attr) { - Log(ctx, logger, slog.LevelError, event, message, attrs...) + log(ctx, logger, slog.LevelError, event, message, attrs...) } func Log(ctx context.Context, logger *slog.Logger, level slog.Level, event, message string, attrs ...slog.Attr) { + log(ctx, logger, level, event, message, attrs...) +} + +const ( + // runtime.Callers → logWith → log → exported wrapper → 실제 호출자 + callerSkipViaWrapper = 4 + // runtime.Callers → logWith → 호출한 helper → 실제 호출자 + callerSkipViaHelper = 3 +) + +func log(ctx context.Context, logger *slog.Logger, level slog.Level, event, message string, attrs ...slog.Attr) { + logWith(ctx, logger, level, event, message, callerSkipViaWrapper, attrs, nil) +} + +// logWith는 level gate 뒤 Record를 직접 구성해 전달한다. Logger.LogAttrs의 두 번째 Enabled +// 호출과 임시 attr 병합 slice를 피하고, Record의 inline attr 저장소를 그대로 활용한다. +// primary와 secondary를 따로 받는 것도 같은 이유다. 호출자가 두 attr 묶음을 미리 합치면 +// 그 병합 slice가 record마다 할당된다. +func logWith(ctx context.Context, logger *slog.Logger, level slog.Level, event, message string, skip int, primary, secondary []slog.Attr) { if logger == nil { return } @@ -33,15 +54,17 @@ func Log(ctx context.Context, logger *slog.Logger, level slog.Level, event, mess return } - contextAttrs := ContextAttrs(ctx) - merged := make([]slog.Attr, 0, 1+len(contextAttrs)+len(attrs)) + var pcs [1]uintptr + runtime.Callers(skip, pcs[:]) + + record := slog.NewRecord(time.Now(), level, logMessage(event, message), pcs[0]) if strings.TrimSpace(event) != "" { - merged = append(merged, Event(event)) + record.AddAttrs(Event(event)) } - merged = append(merged, contextAttrs...) - merged = append(merged, attrs...) - - logger.LogAttrs(ctx, level, logMessage(event, message), merged...) + contextValuesFrom(ctx).addToRecord(&record) + record.AddAttrs(primary...) + record.AddAttrs(secondary...) + _ = logger.Handler().Handle(ctx, record) //nolint:errcheck // slog.Logger의 public logging API도 handler error를 반환하지 않는다 } func logMessage(event, message string) string { diff --git a/pkg/logging/log_and_wrap.go b/pkg/logging/log_and_wrap.go index 146ca9f..e1146b0 100644 --- a/pkg/logging/log_and_wrap.go +++ b/pkg/logging/log_and_wrap.go @@ -12,11 +12,6 @@ func LogAndWrapError(ctx context.Context, logger *slog.Logger, op string, err er return nil } - errorAttrs := ErrorAttrs(err) - mergedAttrs := make([]slog.Attr, 0, len(errorAttrs)+len(attrs)) - mergedAttrs = append(mergedAttrs, errorAttrs...) - mergedAttrs = append(mergedAttrs, attrs...) - - Error(ctx, logger, op+".failed", op+": "+err.Error(), mergedAttrs...) + logWith(ctx, logger, slog.LevelError, op+".failed", op+": "+err.Error(), callerSkipViaHelper, ErrorAttrs(err), attrs) return fmt.Errorf("%s: %w", op, err) } diff --git a/pkg/logging/log_warn.go b/pkg/logging/log_warn.go index e0045ed..3b9c5de 100644 --- a/pkg/logging/log_warn.go +++ b/pkg/logging/log_warn.go @@ -11,10 +11,5 @@ func LogWarnWithErrorAttrs(ctx context.Context, logger *slog.Logger, event, mess return } - errorAttrs := ErrorAttrs(err) - mergedAttrs := make([]slog.Attr, 0, len(errorAttrs)+len(attrs)) - mergedAttrs = append(mergedAttrs, errorAttrs...) - mergedAttrs = append(mergedAttrs, attrs...) - - Warn(ctx, logger, event, message, mergedAttrs...) + logWith(ctx, logger, slog.LevelWarn, event, message, callerSkipViaHelper, ErrorAttrs(err), attrs) } diff --git a/pkg/logging/sanitize.go b/pkg/logging/sanitize.go index 1163264..4b1809d 100644 --- a/pkg/logging/sanitize.go +++ b/pkg/logging/sanitize.go @@ -296,9 +296,10 @@ func (h *sanitizeHandler) WithAttrs(attrs []slog.Attr) slog.Handler { // 열린 group 이름이 privacy·credential key면 그 아래 attr은 key가 무엇이든 값이 그 식별자나 // credential의 구성 요소다. func (h *sanitizeHandler) WithGroup(name string) slog.Handler { + normalizedName := normalizeSensitiveKey(name) return &sanitizeHandler{ inner: h.inner.WithGroup(name), - inMaskedGroup: h.inMaskedGroup || isPrivacyKey(name) || isSensitiveKey(name), + inMaskedGroup: h.inMaskedGroup || isMaskedNormalizedKey(normalizedName), } } @@ -325,38 +326,47 @@ func sanitizeAttrChanged(attr slog.Attr) (slog.Attr, bool) { attr.Value = attr.Value.Resolve() // key 기반 판정은 값을 읽지 않으므로 모든 값 분기(KindAny·KindGroup·KindString)보다 앞이어야 // 한다. 뒤에 두면 마스킹 여부가 값 타입이나 무관한 map 내용에 종속된다. - if isPrivacyKey(attr.Key) || isSensitiveKey(attr.Key) { + normalizedKey := normalizeSensitiveKey(attr.Key) + if isMaskedNormalizedKey(normalizedKey) { return slog.String(attr.Key, redactedValue), true } if attr.Value.Kind() == slog.KindAny { - if raw, ok := attr.Value.Any().(map[string]any); ok { + value := attr.Value.Any() + if raw, ok := value.(map[string]any); ok { if masked, mapChanged := maskPrivacyMap(raw); mapChanged { return slog.Any(attr.Key, masked), true } } - if err, ok := attr.Value.Any().(error); ok { + if err, ok := value.(error); ok { return slog.String(attr.Key, RedactDiagnostic(err.Error())), true } } if attr.Value.Kind() == slog.KindGroup { groupAttrs := attr.Value.Group() - sanitized := make([]any, 0, len(groupAttrs)) - for _, groupAttr := range groupAttrs { - out, c := sanitizeAttrChanged(groupAttr) - if c { - changed = true + for index, groupAttr := range groupAttrs { + out, childChanged := sanitizeAttrChanged(groupAttr) + if !childChanged { + continue + } + + sanitized := make([]slog.Attr, len(groupAttrs)) + copy(sanitized, groupAttrs[:index]) + sanitized[index] = out + for next := index + 1; next < len(groupAttrs); next++ { + sanitized[next] = sanitizeAttr(groupAttrs[next]) } - sanitized = append(sanitized, out) + attr.Value = slog.GroupValue(sanitized...) + return attr, true } - return slog.Group(attr.Key, sanitized...), changed + return attr, changed } if attr.Value.Kind() != slog.KindString { return attr, changed } - if isBroadValueKey(attr.Key) && isSecretLikeValue(attr.Value.String()) { + if isBroadValueNormalizedKey(normalizedKey) && isSecretLikeValue(attr.Value.String()) { return slog.String(attr.Key, redactedValue), true } @@ -368,15 +378,16 @@ func sanitizeAttrChanged(attr slog.Attr) (slog.Attr, bool) { } func isSensitiveKey(key string) bool { - normalized := normalizeSensitiveKey(key) + return isSensitiveNormalizedKey(normalizeSensitiveKey(key)) +} + +func isSensitiveNormalizedKey(normalized string) bool { if normalized == "" { return false } - if _, ok := sensitiveExactKeys[normalized]; ok { return true } - return strings.HasSuffix(normalized, "_token") || strings.HasSuffix(normalized, "_secret") || strings.HasSuffix(normalized, "_password") || @@ -387,6 +398,10 @@ func isSensitiveKey(key string) bool { strings.HasSuffix(normalized, "_secret_key") } +func isMaskedNormalizedKey(normalized string) bool { + return isPrivacyNormalizedKey(normalized) || isSensitiveNormalizedKey(normalized) +} + func normalizeSensitiveKey(key string) string { key = strings.ToLower(strings.TrimSpace(key)) key = strings.ReplaceAll(key, "-", "_") @@ -400,12 +415,20 @@ var broadValueKeys = map[string]struct{}{ } func isBroadValueKey(key string) bool { - _, ok := broadValueKeys[normalizeSensitiveKey(key)] + return isBroadValueNormalizedKey(normalizeSensitiveKey(key)) +} + +func isBroadValueNormalizedKey(normalized string) bool { + _, ok := broadValueKeys[normalized] return ok } func isPrivacyKey(key string) bool { - _, ok := privacyExactKeys[normalizeSensitiveKey(key)] + return isPrivacyNormalizedKey(normalizeSensitiveKey(key)) +} + +func isPrivacyNormalizedKey(normalized string) bool { + _, ok := privacyExactKeys[normalized] return ok } @@ -447,11 +470,12 @@ func maskPrivacyMapDepth(raw map[string]any, depth int) (map[string]any, bool) { } func shouldMaskStructuredMapValue(key string, value any) bool { - if isPrivacyKey(key) || isSensitiveKey(key) { + normalizedKey := normalizeSensitiveKey(key) + if isMaskedNormalizedKey(normalizedKey) { return true } text, ok := value.(string) - return ok && isBroadValueKey(key) && isSecretLikeValue(text) + return ok && isBroadValueNormalizedKey(normalizedKey) && isSecretLikeValue(text) } var secretLikePrefixes = []string{