// Package otelzlog converters hold the functions that are needed to convert // between zerolog and otel logging event types package otelzlog import ( "errors" "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 = errors.New("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 attachSpanError 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 otelzlog provides a bridge between zerolog and otel logging package otelzlog import ( "context" "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 attachSpanError bool 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 }) } // WithAttachSpanError returns an [Option] that configures the [Hook] // to attach errors from `log.Error().Err()` to the associated otel span. func WithAttachSpanError(attach bool) Option { return optFunc(func(c config) config { c.attachSpanError = attach 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 and embeds it in the context to be passed around your app. func New(ctx context.Context, name string, options ...Option) context.Context { logger := log.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, attachSpanError: cfg.attachSpanError, attachSpanEvent: cfg.attachSpanEvent, setSpanError: cfg.setSpanError, setSpanErrorLevel: cfg.setSpanErrorLevel, } if cfg.source { logger = logger.With().CallerWithSkipFrameCount(cfg.sourceOffset + 2).Logger() } ctx = logger.Hook(&hook).WithContext(ctx) return ctx }