Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The next release will require at least [Go 1.25].
### Added

- Support testing of [Go 1.26]. (#7902)
- Add `Err` and `SetErr` on `Record` in `go.opentelemetry.io/otel/log` to attach an error and let the SDK record exception attributes. (#7924)

### Fixed

Expand Down
11 changes: 11 additions & 0 deletions log/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type Record struct {
severity Severity
severityText string
body Value
err error

// The fields below are for optimizing the implementation of Attributes and
// AddAttributes. This design is borrowed from the slog Record type:
Expand Down Expand Up @@ -110,6 +111,16 @@ func (r *Record) SetBody(v Value) {
r.body = v
}

// Err returns the associated error if one has been set.
func (r *Record) Err() error {
return r.err
}

// SetErr sets the associated error. Passing nil clears the error.
func (r *Record) SetErr(err error) {
r.err = err
}

// WalkAttributes walks all attributes the log record holds by calling f for
// each on each [KeyValue] in the [Record]. Iteration stops if f returns false.
func (r *Record) WalkAttributes(f func(KeyValue) bool) {
Expand Down
36 changes: 36 additions & 0 deletions log/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,39 @@ func TestRecordClone(t *testing.T) {
assert.Contains(t, r1Attrs, attr0)
assert.Contains(t, r1Attrs, attr1)
}

func TestRecordErr(t *testing.T) {
tests := []struct {
name string
fn func(*log.Record)
want error
}{
{
name: "zero value",
},
{
name: "set error",
fn: func(r *log.Record) {
r.SetErr(assert.AnError)
},
want: assert.AnError,
},
{
name: "clear error",
fn: func(r *log.Record) {
r.SetErr(assert.AnError)
r.SetErr(nil)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var r log.Record
if tt.fn != nil {
tt.fn(&r)
}
assert.Equal(t, tt.want, r.Err())
})
}
}
68 changes: 68 additions & 0 deletions sdk/log/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@

import (
"context"
"reflect"
"time"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/log"
"go.opentelemetry.io/otel/log/embedded"
"go.opentelemetry.io/otel/sdk/instrumentation"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/trace"
)

var now = time.Now

const (
exceptionTypeKey = string(semconv.ExceptionTypeKey)
exceptionMessageKey = string(semconv.ExceptionMessageKey)
exceptionStacktraceKey = string(semconv.ExceptionStacktraceKey)
)

// Compile-time check logger implements log.Logger.
var _ log.Logger = (*logger)(nil)

Expand Down Expand Up @@ -108,10 +116,70 @@
newRecord.observedTimestamp = now()
}

hasExceptionAttr := false
r.WalkAttributes(func(kv log.KeyValue) bool {
switch kv.Key {
case exceptionTypeKey, exceptionMessageKey, exceptionStacktraceKey:
hasExceptionAttr = true
}
newRecord.AddAttributes(kv)
return true
})

if err := r.Err(); err != nil && !hasExceptionAttr {
addExceptionAttrsFromError(&newRecord, err)

Check failure on line 130 in sdk/log/logger.go

View workflow job for this annotation

GitHub Actions / govulncheck

undefined: addExceptionAttrsFromError
}

return newRecord
}

func addExceptionAttrs(r *Record, err error) {
var attrs [2]log.KeyValue
n := 0
if msg := err.Error(); msg != "" {
if r.attributeCountLimit > 0 && r.attributeCountLimit-r.AttributesLen() < n+1 {
goto flush
}
attrs[n] = log.String(exceptionMessageKey, msg)
n++
}
if errType := errorType(err); errType != "" {
if r.attributeCountLimit > 0 && r.attributeCountLimit-r.AttributesLen() < n+1 {
goto flush
}
attrs[n] = log.String(exceptionTypeKey, errType)
n++
}

flush:
if n > 0 {
r.addAttrs(attrs[:n])
}
}

func errorType(err error) string {
if et, ok := err.(interface{ ErrorType() string }); ok {
if s := et.ErrorType(); s != "" {
return s
}
}

t := reflect.TypeOf(err)
if t == nil {
return ""
}

pkg, name := t.PkgPath(), t.Name()
if pkg != "" && name != "" {
return pkg + "." + name
}

// The type has no package path or name (predeclared, not-defined,
// or alias for a not-defined type).
//
// The type has no package path or name (predeclared, not-defined,
// or alias for a not-defined type).
//
// This is not guaranteed to be unique, but is a best effort.
return t.String()
}
29 changes: 29 additions & 0 deletions sdk/log/logger_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package log // import "go.opentelemetry.io/otel/sdk/log"

import (
"errors"
"testing"
"time"

Expand All @@ -14,6 +15,7 @@ import (
"go.opentelemetry.io/otel/sdk/instrumentation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
)

func BenchmarkLoggerEmit(b *testing.B) {
Expand Down Expand Up @@ -119,6 +121,33 @@ func BenchmarkLoggerEnabled(b *testing.B) {
_ = enabled
}

func BenchmarkLoggerSetErrAndEmit(b *testing.B) {
logger := newTestLogger(b)
err := errors.New("boom")

b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
r := log.Record{}
r.SetErr(err)
logger.Emit(b.Context(), r)
}
}

func BenchmarkLoggerSetExceptionAttributesAndEmit(b *testing.B) {
logger := newTestLogger(b)
err := errors.New("boom")

b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
r := log.Record{}
r.AddAttributes(log.String(string(semconv.ExceptionMessageKey), err.Error()))
r.AddAttributes(log.String(string(semconv.ExceptionTypeKey), errorType(err)))
logger.Emit(b.Context(), r)
}
}

func newTestLogger(t testing.TB) log.Logger {
provider := NewLoggerProvider(
WithProcessor(newFltrProcessor("0", false)),
Expand Down
157 changes: 157 additions & 0 deletions sdk/log/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,163 @@ func TestLoggerEmit(t *testing.T) {
}
}

func TestNewRecordAddsExceptionAttrs(t *testing.T) {
l := newLogger(NewLoggerProvider(), instrumentation.Scope{})

t.Run("AddsMissing", func(t *testing.T) {
var in log.Record
in.SetBody(log.StringValue("boom"))
in.SetSeverity(log.SeverityError)
in.SetErr(errors.New("boom"))
got := l.newRecord(t.Context(), in)

var gotAttrs []log.KeyValue
got.WalkAttributes(func(kv log.KeyValue) bool {
gotAttrs = append(gotAttrs, kv)
return true
})

assert.Len(t, gotAttrs, 2)
assert.Contains(t, gotAttrs, log.String(string(semconv.ExceptionTypeKey), "*errors.errorString"))
assert.Contains(t, gotAttrs, log.String(string(semconv.ExceptionMessageKey), "boom"))
})

t.Run("ShortCircuitsAtAttributeLimit", func(t *testing.T) {
var in log.Record
in.SetBody(log.StringValue("boom"))
in.SetSeverity(log.SeverityError)
in.SetErr(errors.New("boom"))
in.AddAttributes(log.String("k1", "v1"))

lLimited := newLogger(NewLoggerProvider(WithAttributeCountLimit(2)), instrumentation.Scope{})
got := lLimited.newRecord(t.Context(), in)

var gotType, gotMessage string
got.WalkAttributes(func(kv log.KeyValue) bool {
switch kv.Key {
case string(semconv.ExceptionTypeKey):
gotType = kv.Value.AsString()
case string(semconv.ExceptionMessageKey):
gotMessage = kv.Value.AsString()
}
return true
})

assert.Empty(t, gotType)
assert.Equal(t, "boom", gotMessage)
})

t.Run("NoSlotsLeft", func(t *testing.T) {
var in log.Record
in.SetBody(log.StringValue("boom"))
in.SetSeverity(log.SeverityError)
in.SetErr(errors.New("boom"))
in.AddAttributes(log.String("k1", "v1"))
lLimited := newLogger(NewLoggerProvider(WithAttributeCountLimit(1)), instrumentation.Scope{})
got := lLimited.newRecord(t.Context(), in)

var gotType, gotMessage string
got.WalkAttributes(func(kv log.KeyValue) bool {
switch kv.Key {
case string(semconv.ExceptionTypeKey):
gotType = kv.Value.AsString()
case string(semconv.ExceptionMessageKey):
gotMessage = kv.Value.AsString()
}
return true
})

assert.Empty(t, gotType)
assert.Empty(t, gotMessage)
})
}

func TestErrorType(t *testing.T) {
t.Run("UsesErrorTypeMethod", func(t *testing.T) {
err := errWithType{msg: "boom", typ: "custom.type"}
assert.Equal(t, "custom.type", errorType(err))
})

t.Run("FallsBackWhenErrorTypeEmpty", func(t *testing.T) {
err := errWithType{msg: "boom", typ: ""}
assert.Equal(t, "go.opentelemetry.io/otel/sdk/log.errWithType", errorType(err))
})

t.Run("NilError", func(t *testing.T) {
assert.Empty(t, errorType(nil))
})

t.Run("UnnamedType", func(t *testing.T) {
var err error = struct{ baseErr }{}
assert.Contains(t, errorType(err), "struct")
})
}

type errWithType struct {
msg string
typ string
}

func (e errWithType) Error() string { return e.msg }

func (e errWithType) ErrorType() string { return e.typ }

type baseErr struct{}

func (baseErr) Error() string { return "boom" }

func TestNewRecordSkipsExceptionWhenPresent(t *testing.T) {
l := newLogger(NewLoggerProvider(), instrumentation.Scope{})

t.Run("ExistingMessage", func(t *testing.T) {
var r log.Record
r.SetBody(log.StringValue("boom"))
r.SetSeverity(log.SeverityError)
r.SetErr(errors.New("boom"))
r.AddAttributes(log.String(string(semconv.ExceptionMessageKey), "existing.message"))

got := l.newRecord(t.Context(), r)

var gotType, gotMessage string
got.WalkAttributes(func(kv log.KeyValue) bool {
switch kv.Key {
case string(semconv.ExceptionTypeKey):
gotType = kv.Value.AsString()
case string(semconv.ExceptionMessageKey):
gotMessage = kv.Value.AsString()
}
return true
})

assert.Equal(t, "existing.message", gotMessage)
assert.Empty(t, gotType)
})

t.Run("ExistingType", func(t *testing.T) {
var r log.Record
r.SetBody(log.StringValue("boom"))
r.SetSeverity(log.SeverityError)
r.SetErr(errors.New("boom"))
r.AddAttributes(log.String(string(semconv.ExceptionTypeKey), "existing.type"))

got := l.newRecord(t.Context(), r)

var gotType, gotMessage string
got.WalkAttributes(func(kv log.KeyValue) bool {
switch kv.Key {
case string(semconv.ExceptionTypeKey):
gotType = kv.Value.AsString()
case string(semconv.ExceptionMessageKey):
gotMessage = kv.Value.AsString()
}
return true
})

assert.Equal(t, "existing.type", gotType)
assert.Empty(t, gotMessage)
})
}

func TestLoggerEnabled(t *testing.T) {
p0 := newFltrProcessor("0", true)
p1 := newFltrProcessor("1", true)
Expand Down
Loading
Loading