From 9c0b6a4163434249a5bd3ad01520bd12a2539319 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Sun, 23 Aug 2026 12:45:58 +0200 Subject: [PATCH 1/2] otel: add option to configure log level Add WithLevel to configure the minimum log level handled by the OpenTelemetry hook. This allows callers to limit which log entries are recorded as span events, reducing trace noise and overhead when lower-severity logs are not useful for tracing. By default, the hook continues to handle all log levels, preserving the existing behavior. Signed-off-by: Sebastiaan van Stijn --- otel/log.go | 24 +++++++++++++++++++- otel/log_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/otel/log.go b/otel/log.go index 8b41f37..28fd8ed 100644 --- a/otel/log.go +++ b/otel/log.go @@ -21,6 +21,8 @@ package otel import ( + "slices" + "github.com/containerd/log" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -47,6 +49,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 +61,19 @@ 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 + } + } + } +} + // 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 +81,15 @@ 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 + 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. diff --git a/otel/log_test.go b/otel/log_test.go index d68dfa8..1e4ee0f 100644 --- a/otel/log_test.go +++ b/otel/log_test.go @@ -19,8 +19,10 @@ package otel_test import ( "context" "io" + "slices" "testing" + "github.com/containerd/log" "github.com/containerd/log/otel" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" @@ -106,3 +108,58 @@ 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) + } + }) + } +} From 65e60ca338ef1f58d9cdb5a0619be6bd6328cd5f Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Sun, 23 Aug 2026 12:52:36 +0200 Subject: [PATCH 2/2] otel: add option to set span error status Add WithErrorStatusLevel to configure the minimum log level that marks an active span with an error status. This allows callers to reflect sufficiently severe log entries in the span status while keeping the behavior independent from attached error fields. By default, log entries continue to leave the span status unchanged. Signed-off-by: Sebastiaan van Stijn --- otel/log.go | 17 +++++++++ otel/log_test.go | 93 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/otel/log.go b/otel/log.go index 28fd8ed..3233eff 100644 --- a/otel/log.go +++ b/otel/log.go @@ -25,6 +25,7 @@ import ( "github.com/containerd/log" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) @@ -74,6 +75,14 @@ func WithLevel(level log.Level) HookOpt { } } +// 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. @@ -81,6 +90,7 @@ func WithLevel(level log.Level) 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 } @@ -119,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 1e4ee0f..d3755b5 100644 --- a/otel/log_test.go +++ b/otel/log_test.go @@ -18,14 +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" ) @@ -36,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 @@ -163,3 +181,78 @@ func TestLogrusHookLevels(t *testing.T) { }) } } + +// 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) + } + }) + } +}