Files
go-libs/pkg/observe/log/adapter_zap_logger_test.go
a850fc9c24 feat(logging): add Trace level to Logger (#608)
* feat(logging): add Trace level to Logger

Add a TraceLevel constant (value -1, more verbose than Debug) and
Trace/Tracef methods to the Logger interface. Trace is intended for
per-event/per-request logging that is too noisy for Debug.

- TraceLevel is exposed alongside Debug/Info/Error in the Level enum.
- LogrusLogger forwards Trace/Tracef to logrus's native Trace level.
- NewDefaultLoggerWithLevel lets callers pick a precise level (the
  existing NewDefaultLogger(w, debug, ...) wrapper is preserved for
  backward compatibility).
- ParseLevel(string) and Level.String() are added for CLI parsing
  and structured logging.
- Package-level Trace/Tracef helpers mirror the existing Debug/Info
  helpers and delegate to FromContext.
- The generated mock is updated; the local mockLogger in
  pkg/authn/licence/licence_test.go now implements Trace/Tracef.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(logging): add ZapLogger adapter with Trace support

Add a zap-backed implementation of the Logger interface, mirroring the
existing LogrusLogger but built on go.uber.org/zap. Trace records are
emitted at a custom zapcore.Level (-2, below DebugLevel) so cores that
opt out of Trace — typically OTel exporter bridges wrapped with
MinLevelCore — silently drop them.

Public surface:
- ZapLogger struct, NewZap(sugar), NopZap() — drop-in Logger backed by
  *zap.SugaredLogger.
- (*ZapLogger).Zap() exposes the underlying *zap.Logger for libraries
  that require it directly (etcd WAL, gRPC instrumentation, ...).
- ToZapLevel(Level) and EncodeLevelWithTrace(base) help build pipelines
  that handle the custom Trace level correctly.
- MinLevelCore wraps a zapcore.Core to enforce a minimum level — meant
  to keep Trace records off the OTel bridge while letting them flow to
  the console core.

This backport unblocks services (ledger-v3) that already maintain a
local copy of this adapter; centralising it here avoids divergence
and keeps the Trace semantics consistent across the platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(logging): regenerate mock to match mockgen output

The hand-edited mock placed Trace/Tracef before Writer instead of in the
order mockgen produces. Running `go generate ./...` in CI flagged the
diff via the "Dirty" check. Regenerated via mockgen to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(logging): cover Trace level, ParseLevel, and MinLevelCore

Add unit tests for the new public surface so codecov picks up the patch
coverage:

- ParseLevel: accepts trace|debug|info|error case-insensitively, rejects
  unknown strings.
- Level.String: round-trips the supported levels and falls back to a
  readable form for unknown values.
- ZapLogger: Trace records are emitted when the configured level is
  TraceLevel and silently dropped at DebugLevel; Enabled returns the
  expected truth table across all four levels; WithFields propagates.
- NopZap: never emits and reports Enabled=false.
- MinLevelCore: the canonical pipeline (console accepts Trace, OTel core
  wrapped with MinLevelCore at DebugLevel) routes Trace to the console
  only and lets Debug through both cores.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-02 12:30:32 +00:00

187 lines
5.1 KiB
Go

package logging
import (
"bytes"
"strings"
"sync"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// newTestZapLogger builds a ZapLogger writing to buf at the given level.
// The console core uses ToZapLevel + EncodeLevelWithTrace so the test
// exercises the full pipeline.
func newTestZapLogger(buf *bytes.Buffer, level Level) (*ZapLogger, *threadSafeSyncer) {
sink := &threadSafeSyncer{buf: buf}
encCfg := zap.NewProductionEncoderConfig()
encCfg.EncodeLevel = EncodeLevelWithTrace(zapcore.LowercaseLevelEncoder)
enc := zapcore.NewJSONEncoder(encCfg)
core := zapcore.NewCore(enc, sink, ToZapLevel(level))
return NewZap(zap.New(core).Sugar()), sink
}
// threadSafeSyncer wraps a bytes.Buffer with a mutex; zapcore.AddSync
// alone doesn't synchronise concurrent writes, which trips the race
// detector when the Writer() goroutine logs in parallel with assertions.
type threadSafeSyncer struct {
mu sync.Mutex
buf *bytes.Buffer
}
func (s *threadSafeSyncer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.Write(p)
}
func (s *threadSafeSyncer) Sync() error { return nil }
func (s *threadSafeSyncer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.String()
}
func TestZapLoggerTraceEmittedAtTraceLevel(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger, sink := newTestZapLogger(&buf, TraceLevel)
logger.Trace("trace-msg")
logger.Tracef("trace-fmt-%d", 42)
logger.Debug("debug-msg")
logger.Info("info-msg")
out := sink.String()
for _, want := range []string{"trace-msg", "trace-fmt-42", "debug-msg", "info-msg", `"level":"trace"`} {
if !strings.Contains(out, want) {
t.Errorf("expected output to contain %q; got:\n%s", want, out)
}
}
}
func TestZapLoggerTraceSilentAtDebugLevel(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger, sink := newTestZapLogger(&buf, DebugLevel)
logger.Trace("should-not-appear")
logger.Tracef("nor-%s", "this")
logger.Debug("debug-still-emitted")
out := sink.String()
if strings.Contains(out, "should-not-appear") || strings.Contains(out, "nor-this") {
t.Errorf("trace records leaked into Debug-level output:\n%s", out)
}
if !strings.Contains(out, "debug-still-emitted") {
t.Errorf("debug record missing from output:\n%s", out)
}
}
func TestZapLoggerEnabled(t *testing.T) {
t.Parallel()
cases := []struct {
configured Level
check Level
want bool
}{
{TraceLevel, TraceLevel, true},
{TraceLevel, DebugLevel, true},
{TraceLevel, InfoLevel, true},
{DebugLevel, TraceLevel, false},
{DebugLevel, DebugLevel, true},
{InfoLevel, DebugLevel, false},
{InfoLevel, InfoLevel, true},
{ErrorLevel, InfoLevel, false},
{ErrorLevel, ErrorLevel, true},
}
for _, tc := range cases {
var buf bytes.Buffer
logger, _ := newTestZapLogger(&buf, tc.configured)
if got := logger.Enabled(tc.check); got != tc.want {
t.Errorf("Enabled(%v) on level=%v = %v, want %v", tc.check, tc.configured, got, tc.want)
}
}
}
func TestZapLoggerWithFields(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
base, sink := newTestZapLogger(&buf, TraceLevel)
child := base.WithFields(map[string]any{"req_id": "abc", "n": 7})
child.Tracef("tagged")
out := sink.String()
for _, want := range []string{`"req_id":"abc"`, `"n":7`, "tagged"} {
if !strings.Contains(out, want) {
t.Errorf("expected output to contain %q; got:\n%s", want, out)
}
}
}
func TestNopZap(t *testing.T) {
t.Parallel()
l := NopZap()
// All methods should be safe to call and produce no output.
l.Trace("x")
l.Tracef("x-%d", 1)
l.Debug("x")
l.Info("x")
l.Error("x")
l.WithField("k", "v").Trace("y")
if l.Enabled(ErrorLevel) {
t.Errorf("NopZap should report all levels as disabled")
}
}
// TestMinLevelCoreFiltersTrace verifies the canonical use case: a console
// core accepting Trace, a second core (e.g. OTel bridge) wrapped with
// MinLevelCore at DebugLevel rejecting Trace. Trace records must reach
// the console only.
func TestMinLevelCoreFiltersTrace(t *testing.T) {
t.Parallel()
var consoleBuf, otelBuf bytes.Buffer
consoleSink := &threadSafeSyncer{buf: &consoleBuf}
otelSink := &threadSafeSyncer{buf: &otelBuf}
encCfg := zap.NewProductionEncoderConfig()
encCfg.EncodeLevel = EncodeLevelWithTrace(zapcore.LowercaseLevelEncoder)
enc := zapcore.NewJSONEncoder(encCfg)
consoleCore := zapcore.NewCore(enc, consoleSink, ToZapLevel(TraceLevel))
otelCore := &MinLevelCore{
Core: zapcore.NewCore(enc, otelSink, ToZapLevel(TraceLevel)),
Min: zapcore.DebugLevel,
}
logger := NewZap(zap.New(zapcore.NewTee(consoleCore, otelCore)).Sugar())
logger.Trace("trace-only-on-console")
logger.Debug("debug-on-both")
consoleOut := consoleSink.String()
otelOut := otelSink.String()
if !strings.Contains(consoleOut, "trace-only-on-console") {
t.Errorf("console core missed trace record:\n%s", consoleOut)
}
if strings.Contains(otelOut, "trace-only-on-console") {
t.Errorf("MinLevelCore failed to drop trace from OTel core:\n%s", otelOut)
}
if !strings.Contains(consoleOut, "debug-on-both") || !strings.Contains(otelOut, "debug-on-both") {
t.Errorf("debug record should reach both cores; console=%q otel=%q", consoleOut, otelOut)
}
}