diff --git a/otel/log.go b/otel/log.go index 8b41f37..3233eff 100644 --- a/otel/log.go +++ b/otel/log.go @@ -21,8 +21,11 @@ package otel import ( + "slices" + "github.com/containerd/log" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) @@ -47,6 +50,9 @@ func NewLogrusHook(opts ...HookOpt) *LogrusHook { for _, opt := range opts { opt(hook) } + if hook.levels == nil { + hook.levels = slices.Clone(allLevels) + } return hook } @@ -56,6 +62,27 @@ func WithTraceIDField(enabled bool) HookOpt { } } +// WithLevel configures the minimum log level handled by the hook. +// Entries below this level are ignored. +func WithLevel(level log.Level) HookOpt { + return func(h *LogrusHook) { + for i, l := range allLevels { + if l == level { + h.levels = slices.Clone(allLevels[:i+1]) + return + } + } + } +} + +// WithErrorStatusLevel configures the minimum log level that marks the +// active span with an error status. +func WithErrorStatusLevel(level log.Level) HookOpt { + return func(h *LogrusHook) { + h.errorStatusLevel = &level + } +} + // LogrusHook is a [logrus.Hook] which adds logrus events to active spans. // If the span is not recording or the span context is invalid, the hook // is a no-op. @@ -63,11 +90,16 @@ func WithTraceIDField(enabled bool) HookOpt { // [logrus.Hook]: https://github.com/sirupsen/logrus/blob/v1.9.3/hooks.go#L3-L11 type LogrusHook struct { enableTraceIDField bool + errorStatusLevel *log.Level + levels []log.Level } // Levels returns the logrus levels that this hook is interested in. func (h *LogrusHook) Levels() []log.Level { - return allLevels + if h.levels == nil { + return allLevels + } + return h.levels } // Fire is called when a log event occurs. @@ -97,6 +129,13 @@ func (h *LogrusHook) Fire(entry *log.Entry) error { trace.WithTimestamp(entry.Time), ) + // Set the span status based on the log level, rather than the presence of + // an error field. Error values may be attached to lower-severity log entries + // without indicating that the operation represented by the span failed. + if h.errorStatusLevel != nil && entry.Level <= *h.errorStatusLevel { + span.SetStatus(codes.Error, entry.Message) + } + return nil } diff --git a/otel/log_test.go b/otel/log_test.go index d68dfa8..d3755b5 100644 --- a/otel/log_test.go +++ b/otel/log_test.go @@ -18,12 +18,17 @@ package otel_test import ( "context" + "errors" "io" + "slices" "testing" + "time" + "github.com/containerd/log" "github.com/containerd/log/otel" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) @@ -34,6 +39,21 @@ var ( testSpanID = trace.SpanID{1, 2, 3, 4, 5, 6, 7, 8} ) +// testSpan is a minimal recording span used to test hook behavior without +// depending on the OpenTelemetry SDK. +type testSpan struct { + trace.Span + status codes.Code +} + +func (s *testSpan) SpanContext() trace.SpanContext { + return trace.NewSpanContext(trace.SpanContextConfig{TraceID: testTraceID, SpanID: testSpanID}) +} + +func (s *testSpan) IsRecording() bool { return true } +func (s *testSpan) AddEvent(string, ...trace.EventOption) {} +func (s *testSpan) SetStatus(code codes.Code, _ string) { s.status = code } + func TestLogrusHookTraceID(t *testing.T) { tests := []struct { name string @@ -106,3 +126,133 @@ func TestLogrusHookTraceID(t *testing.T) { }) } } + +// TestLogrusHookLevels verifies that [WithLevel] limits the levels handled by +// the hook while preserving all levels by default. +func TestLogrusHookLevels(t *testing.T) { + tests := []struct { + name string + opts []otel.HookOpt + want []log.Level + }{ + { + name: "default", + want: []log.Level{ + log.PanicLevel, + log.FatalLevel, + log.ErrorLevel, + log.WarnLevel, + log.InfoLevel, + log.DebugLevel, + log.TraceLevel, + }, + }, + { + name: "warn", + opts: []otel.HookOpt{ + otel.WithLevel(log.WarnLevel), + }, + want: []log.Level{ + log.PanicLevel, + log.FatalLevel, + log.ErrorLevel, + log.WarnLevel, + }, + }, + { + name: "error", + opts: []otel.HookOpt{ + otel.WithLevel(log.ErrorLevel), + }, + want: []log.Level{ + log.PanicLevel, + log.FatalLevel, + log.ErrorLevel, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + hook := otel.NewLogrusHook(tc.opts...) + if got := hook.Levels(); !slices.Equal(got, tc.want) { + t.Errorf("Levels() = %v; want %v", got, tc.want) + } + }) + } +} + +// TestLogrusHookErrorStatusLevel verifies that [WithErrorStatusLevel] marks +// spans as errors based on log severity and leaves span status unchanged by +// default. +func TestLogrusHookErrorStatusLevel(t *testing.T) { + tests := []struct { + name string + opts []otel.HookOpt + level log.Level + fields log.Fields + wantError bool + }{ + { + name: "default", + level: log.ErrorLevel, + }, + { + name: "below threshold", + opts: []otel.HookOpt{ + otel.WithErrorStatusLevel(log.ErrorLevel), + }, + level: log.WarnLevel, + }, + { + name: "at threshold", + opts: []otel.HookOpt{ + otel.WithErrorStatusLevel(log.ErrorLevel), + }, + level: log.ErrorLevel, + wantError: true, + }, + { + name: "above threshold", + opts: []otel.HookOpt{ + otel.WithErrorStatusLevel(log.ErrorLevel), + }, + level: log.FatalLevel, + wantError: true, + }, + { + name: "error field below threshold", + opts: []otel.HookOpt{ + otel.WithErrorStatusLevel(log.ErrorLevel), + }, + level: log.DebugLevel, + fields: log.Fields{ + "error": errors.New("ignored"), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + span := &testSpan{} + ctx := trace.ContextWithSpan(context.Background(), span) + + hook := otel.NewLogrusHook(tc.opts...) + err := hook.Fire(&log.Entry{ + Context: ctx, + Data: tc.fields, + Level: tc.level, + Message: "message", + Time: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + + gotError := span.status == codes.Error + if gotError != tc.wantError { + t.Errorf("span error status = %v; want %v", gotError, tc.wantError) + } + }) + } +}