// Package otelzlog converters hold the functions that are needed to convert
// between zerolog and otel logging event types
package otelzlog
import (
"fmt"
"math"
"reflect"
"strconv"
"strings"
"time"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/log"
)
// convertLevel converts the logging level from a zerolog.Level into a an otel log.Severity
// and the corresponding severity level string
func convertLevel(level zerolog.Level) (log.Severity, string) {
switch level {
case zerolog.TraceLevel:
return log.SeverityTrace, "TRACE"
case zerolog.DebugLevel:
return log.SeverityDebug, "DEBUG"
default:
fallthrough
case zerolog.InfoLevel:
return log.SeverityInfo, "INFO"
case zerolog.WarnLevel:
return log.SeverityWarn, "WARN"
case zerolog.ErrorLevel:
return log.SeverityError, "ERROR"
case zerolog.FatalLevel:
return log.SeverityFatal, "FATAL"
case zerolog.PanicLevel:
return log.SeverityFatal, "FATAL"
}
}
// convertAttribute converts value from `any` into the equivalent otel log.Value.
// This function is a direct copy paste from the otelslog package.
func convertAttribute(v any) log.Value {
switch val := v.(type) {
case bool:
return log.BoolValue(val)
case string:
return log.StringValue(val)
case int:
return log.Int64Value(int64(val))
case int8:
return log.Int64Value(int64(val))
case int16:
return log.Int64Value(int64(val))
case int32:
return log.Int64Value(int64(val))
case int64:
return log.Int64Value(val)
case uint:
return convertUintValue(uint64(val))
case uint8:
return log.Int64Value(int64(val))
case uint16:
return log.Int64Value(int64(val))
case uint32:
return log.Int64Value(int64(val))
case uint64:
return convertUintValue(val)
case uintptr:
return convertUintValue(uint64(val))
case float32:
return log.Float64Value(float64(val))
case float64:
return log.Float64Value(val)
case time.Duration:
return log.Int64Value(val.Nanoseconds())
case complex64:
r := log.Float64("r", real(complex128(val)))
i := log.Float64("i", imag(complex128(val)))
return log.MapValue(r, i)
case complex128:
r := log.Float64("r", real(val))
i := log.Float64("i", imag(val))
return log.MapValue(r, i)
case time.Time:
return log.Int64Value(val.UnixNano())
case []byte:
return log.BytesValue(val)
case error:
return log.StringValue(val.Error())
}
t := reflect.TypeOf(v)
if t == nil {
return log.Value{}
}
val := reflect.ValueOf(v)
switch t.Kind() {
case reflect.Struct:
return log.StringValue(fmt.Sprintf("%+v", v))
case reflect.Slice, reflect.Array:
items := make([]log.Value, 0, val.Len())
for i := range val.Len() {
items = append(items, convertAttribute(val.Index(i).Interface()))
}
return log.SliceValue(items...)
case reflect.Map:
kvs := make([]log.KeyValue, 0, val.Len())
for _, k := range val.MapKeys() {
var key string
switch k.Kind() {
case reflect.String:
key = k.String()
default:
key = fmt.Sprintf("%+v", k.Interface())
}
kvs = append(kvs, log.KeyValue{
Key: key,
Value: convertAttribute(val.MapIndex(k).Interface()),
})
}
return log.MapValue(kvs...)
case reflect.Ptr, reflect.Interface:
if val.IsNil() {
return log.Value{}
}
return convertAttribute(val.Elem().Interface())
}
// Try to handle this as gracefully as possible.
//
// Don't panic here. it is preferable to have user's open issue
// asking why their attributes have a "unhandled: " prefix than
// say that their code is panicking.
return log.StringValue(fmt.Sprintf("unhandled: (%s) %+v", t, v))
}
func convertUintValue(v uint64) log.Value {
if v > math.MaxInt64 {
return log.StringValue(strconv.FormatUint(v, 10))
}
return log.Int64Value(int64(v))
}
func convertLogToAttribute(attr log.Value) attribute.Value {
switch attr.Kind() {
case log.KindString:
return attribute.StringValue(attr.String())
case log.KindFloat64:
return attribute.Float64Value(attr.AsFloat64())
case log.KindInt64:
return attribute.Int64Value(attr.AsInt64())
case log.KindBool:
return attribute.BoolValue(attr.AsBool())
case log.KindBytes:
return attribute.StringValue(string(attr.AsBytes()))
case log.KindSlice:
return attribute.StringValue(fmt.Sprintf("%v", attr.AsSlice()))
case log.KindMap:
return attribute.StringValue(fmt.Sprintf("%v", attr.AsMap()))
case log.KindEmpty:
return attribute.StringValue("")
}
return attribute.StringValue(attr.AsString())
}
func extractSource(source string) (filepath string, line int, err error) {
colonSplit := strings.Split(source, ":")
if len(colonSplit) != 2 {
err = fmt.Errorf("otelzlog: source does not contain path and line number")
return
}
line, err = strconv.Atoi(colonSplit[1])
if err != nil {
return
}
filepath = colonSplit[0]
return
}
// Package otelzlog hook holds the hook that is attached to the zerolog logger
package otelzlog
import (
"context"
"encoding/json"
"fmt"
"reflect"
"time"
"github.com/rs/zerolog"
zlog "github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
otelLog "go.opentelemetry.io/otel/log"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
"go.opentelemetry.io/otel/trace"
)
// Hook is the parent struct of the otelzlog handler
type Hook struct {
otelLogger otelLog.Logger
source bool
attachSpanEvent bool
setSpanError bool
setSpanErrorLevel zerolog.Level
}
// Run extracts the attributes and log level from the `*zerolog.Event`, and
// pulls the span from the passed in context in order to build the respective
// otel log.Record
func (h *Hook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
ctx := e.GetCtx()
// return early if the logger isn't enabled for this log level
if !e.Enabled() {
return
}
var logData map[string]any
ev := fmt.Sprintf("%s}", reflect.ValueOf(e).Elem().FieldByName("buf"))
if err := json.Unmarshal([]byte(ev), &logData); err != nil {
// log to the zerolog logger if there is an error reflecting the event's attribute buffer
zlog.Ctx(e.GetCtx()).Error().Ctx(e.GetCtx()).
Err(err).
Str("log.level", level.String()).
Str("log.message", msg).
Msg("could not unmarshal the zerolog event's attribute buffer")
}
// convert zerolog attrs into otel log and span attrs
logAttributes := h.processSpanAttrs(ctx, msg, logData, level)
// create the otel log event and send it
h.sendLogMessage(ctx, msg, level, logAttributes)
}
// processSpanAttrs converts each pulled attribute into the equivalent otel log counterparts.
// It also adds the attributes into the span and adds the error as an exception.
func (h *Hook) processSpanAttrs(ctx context.Context, msg string, logData map[string]any, level zerolog.Level) (logAttributes []otelLog.KeyValue) {
for k, v := range logData {
switch k {
// if there is an attribute called "error", then record the error in the span and
// add it to the log attributes only (not the trace attributes)
case zerolog.ErrorFieldName:
logAttributes = append(logAttributes,
otelLog.String(string(semconv.ExceptionMessageKey), v.(string)),
otelLog.String("event", "exception"),
)
// if there is an attribute called "stack", then record the stack in the span and
// add it to the log attributes only (not the trace attributes)
case zerolog.ErrorStackFieldName:
logAttributes = append(logAttributes,
otelLog.String(string(semconv.ExceptionStacktraceKey), v.(string)),
)
// If there is a "caller" object in the log and if source is enabled in [Hook], then
// append these using semconv fields instead of generic string attributes.
case zerolog.CallerFieldName:
sourcePath, ok := v.(string)
if !ok || !h.source {
continue
}
filepath, line, err := extractSource(sourcePath)
if err != nil {
continue
}
logAttributes = append(logAttributes,
otelLog.String(string(semconv.CodeFilepathKey), filepath),
otelLog.Int(string(semconv.CodeLineNumberKey), line),
)
default:
logAttributes = append(logAttributes, otelLog.KeyValue{
Key: k,
Value: convertAttribute(v),
})
}
}
// If enabled, add an otel span event (attach the log to the span).
if h.attachSpanEvent {
traceAttributes := []attribute.KeyValue{}
for _, logAttr := range logAttributes {
traceAttributes = append(traceAttributes, attribute.KeyValue{
Key: attribute.Key(logAttr.Key),
Value: convertLogToAttribute(logAttr.Value),
})
}
trace.SpanFromContext(ctx).AddEvent(msg,
trace.WithAttributes(traceAttributes...),
)
}
if h.setSpanError && level >= h.setSpanErrorLevel {
trace.SpanFromContext(ctx).SetStatus(codes.Error, "")
}
return
}
func (h *Hook) sendLogMessage(ctx context.Context, msg string, level zerolog.Level, logAttributes []otelLog.KeyValue) {
severityNumber, severityText := convertLevel(level)
record := otelLog.Record{}
record.SetTimestamp(time.Now())
record.SetBody(otelLog.StringValue(msg))
record.SetSeverity(severityNumber)
record.SetSeverityText(severityText)
record.AddAttributes(logAttributes...)
h.otelLogger.Emit(ctx, record)
}
// Package otelrecorder provides a utility for recording OpenTelemetry logs and traces in tests.
package otelrecorder
import (
"context"
"fmt"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/log"
"go.opentelemetry.io/otel/log/logtest"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
// Recorder is a utility for recording OpenTelemetry logs and traces during tests.
type Recorder struct {
LogProvider *logtest.Recorder
TracerProvider *trace.TracerProvider
TraceRecorder *tracetest.InMemoryExporter
}
// NewRecorder initializes a new Recorder instance with a log provider and a trace recorder.
func NewRecorder() *Recorder {
r := &Recorder{}
r.LogProvider = logtest.NewRecorder()
r.TraceRecorder = tracetest.NewInMemoryExporter()
r.TracerProvider = trace.NewTracerProvider(
trace.WithSampler(trace.AlwaysSample()),
trace.WithSyncer(r.TraceRecorder),
trace.WithResource(resource.Empty()),
)
return r
}
// Cleanup shuts down the trace recorder and tracer provider, releasing any resources they hold.
func (r *Recorder) Cleanup() {
if err := r.TraceRecorder.Shutdown(context.Background()); err != nil {
fmt.Printf("error shutting down otel trace exporter: %v\n", err)
}
if err := r.TracerProvider.Shutdown(context.Background()); err != nil {
fmt.Printf("error shutting down otel trace provider: %v\n", err)
}
}
// GetLogs retrieves all recorded logs from the log provider.
func (r *Recorder) GetLogs() []logtest.Record {
records := []logtest.Record{}
for _, recorded := range r.LogProvider.Result() {
records = append(records, recorded...)
}
return records
}
// GetSpans retrieves all recorded spans from the trace recorder and returns them as a map keyed by span name.
func (r *Recorder) GetSpans() map[string]tracetest.SpanStub {
out := map[string]tracetest.SpanStub{}
for _, span := range r.TraceRecorder.GetSpans() {
out[span.Name] = span
}
return out
}
// AttributeKVListToMap converts a slice of OpenTelemetry attribute.KeyValue to a map.
func AttributeKVListToMap(attrs []attribute.KeyValue) map[string]attribute.Value {
out := map[string]attribute.Value{}
for _, attr := range attrs {
out[string(attr.Key)] = attr.Value
}
return out
}
// LogKVListToMap converts a slice of OpenTelemetry log.KeyValue to a map.
func LogKVListToMap(attrs []log.KeyValue) map[string]log.Value {
out := map[string]log.Value{}
for _, attr := range attrs {
out[attr.Key] = attr.Value
}
return out
}
// Package otelzlog provides a bridge between zerolog and otel logging
package otelzlog
import (
"io"
"runtime"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
otelLog "go.opentelemetry.io/otel/log"
"go.opentelemetry.io/otel/log/global"
)
type config struct {
provider otelLog.LoggerProvider
source bool
sourceOffset int
attachSpanEvent bool
setSpanError bool
setSpanErrorLevel zerolog.Level
writers []io.Writer
loggerOpts []otelLog.LoggerOption
}
// Option configures the zerolog hook.
type Option interface {
apply(config) config
}
type optFunc func(config) config
func (f optFunc) apply(c config) config {
return f(c)
}
// WithVersion returns an [Option] that configures the version of the
// [log.Logger] used by a [Hoo]. The version should be the version of the
// package that is being logged.
func WithVersion(version string) Option {
return optFunc(func(c config) config {
c.loggerOpts = append(c.loggerOpts, otelLog.WithInstrumentationVersion(version))
return c
})
}
// WithSchemaURL returns an [Option] that configures the semantic convention
// schema URL of the [log.Logger] used by a [Hook]. The schemaURL should be
// the schema URL for the semantic conventions used in log records.
func WithSchemaURL(schemaURL string) Option {
return optFunc(func(c config) config {
c.loggerOpts = append(c.loggerOpts, otelLog.WithSchemaURL(schemaURL))
return c
})
}
// WithAttributes returns an [Option] that configures the instrumentation scope
// attributes of the [log.Logger] used by a [Hook].
func WithAttributes(attributes ...attribute.KeyValue) Option {
return optFunc(func(c config) config {
c.loggerOpts = append(c.loggerOpts, otelLog.WithInstrumentationAttributes(attributes...))
return c
})
}
// WithLoggerProvider returns an [Option] that configures [log.LoggerProvider]
// used by a [Hook] to create its [log.Logger].
//
// By default if this Option is not provided, the Handler will use the global
// LoggerProvider.
func WithLoggerProvider(provider otelLog.LoggerProvider) Option {
return optFunc(func(c config) config {
c.provider = provider
return c
})
}
// WithWriter returns an [Option] that configures writers used by a
// [Hook]. Multiple writers can be specified.
func WithWriter(w io.Writer) Option {
return optFunc(func(c config) config {
c.writers = append(c.writers, w)
return c
})
}
// WithSource returns an [Option] that configures the [Hook] to include
// the source location of the log record in log attributes. Offset should
// be increased if using a helper function to wrap the logger call.
func WithSource(source bool, offset int) Option {
return optFunc(func(c config) config {
c.source = source
c.sourceOffset = offset
return c
})
}
// WithAttachSpanEvent returns an [Option] that configures the [Hook]
// to attach an event to the otel span the zerolog event.
func WithAttachSpanEvent(attach bool) Option {
return optFunc(func(c config) config {
c.attachSpanEvent = attach
return c
})
}
// WithSetSpanErrorStatus returns an [Option] that configures the [Hook]
// to set the span as errored when the provided level or higher is called.
func WithSetSpanErrorStatus(set bool, level zerolog.Level) Option {
return optFunc(func(c config) config {
c.setSpanError = set
c.setSpanErrorLevel = level
return c
})
}
// WithStackHandling returns an [Option] that sets zerolog.ErrorStackMarshaler
// in order to extract the stack when .Stack() is called on a .Error() event.
//
// A Str(). called "stack" can also be passed in and will be set in the OTEL
// logs/traces accordingly.
func WithStackHandling() Option {
return optFunc(func(c config) config {
zerolog.ErrorStackMarshaler = func(_ error) any {
buf := make([]byte, 64<<10)
n := runtime.Stack(buf, false)
return string(buf[:n])
}
return c
})
}
func newCfg(options []Option) config {
var c config
for _, opt := range options {
c = opt.apply(c)
}
if c.provider == nil {
c.provider = global.GetLoggerProvider()
}
return c
}
// New creates a new *zerolog.Logger.
func New(name string, options ...Option) *zerolog.Logger {
var logger zerolog.Logger
cfg := newCfg(options)
switch {
case len(cfg.writers) == 0:
logger = log.Logger
default:
logger = logger.Output(io.MultiWriter(cfg.writers...))
}
hook := Hook{
otelLogger: cfg.provider.Logger(name, cfg.loggerOpts...),
source: cfg.source,
attachSpanEvent: cfg.attachSpanEvent,
setSpanError: cfg.setSpanError,
setSpanErrorLevel: cfg.setSpanErrorLevel,
}
if cfg.source {
logger = logger.With().CallerWithSkipFrameCount(cfg.sourceOffset + 2).Timestamp().Logger()
}
logger = logger.Hook(&hook)
return &logger
}