Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion otel/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
}

Expand All @@ -56,18 +62,44 @@ 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.
//
// [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
}
Comment thread
thaJeztah marked this conversation as resolved.
Comment on lines 97 to 103

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, alright, I'll add it back, but 😠 don't come back to comment "you should not return allLevels without cloning!


// Fire is called when a log event occurs.
Expand Down Expand Up @@ -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
}

Expand Down
150 changes: 150 additions & 0 deletions otel/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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)
}
})
}
}
Loading