It’s been a while since I posted anything, but this took more time to get working than I care to admit. So, I thought I’d make a few notes about it, firstly for future reference, but also in case it’s useful to anyone else.
Essentially though, this post outlines how to add a trace ID to your logs in order to correlate them with your traces. Specifically in this case we’re using Datadog and the new structured logging package (slog) from the Go standard library.
If you’re reading this, then you probably already know why you’d want to do this, but just in case, here is an overview from Datadog which suggests that it requires zero developer time – apparently! 🙄
- Structured Logging
- Obtaining the Trace ID
- Context
- Embedding Structs
- Additional Required Methods
- Adding the Trace ID from Context
- TL;DR - Show Me The Code
- References
Structured Logging
In order to correlate our logs with our traces, we must first ensure our logs are sent to Datadog as JSON. Fortunately for us we now use the structured logging package that is included with Go 1.21 and already send our logs as json using the JSONHandler
. There are already plenty of tutorials for slog so I won’t cover that too much here.
The Datadog documentation on this subject however is quite short and does not specifically cover how we’d use slog for this purpose. Instead they reference standard log statements and suggest using the %v
format specifier.
This alone is quite interesting and could definitely send you down a rabbit hole trying to work how using that specifier results in the relevant data being logged. Suffice to say, I got lost for a while looking at MessagePack and the msgp package, but I still have some digging to do on that.
In addition, if you’re using some other logging library, such as logrus, there may be an alternative solution.
Obtaining the Trace ID
On the surface, this looks to be a simple problem to solve. When we perform a log statement, if we have a trace ID, we can simply log that alongside our other log attributes with slog.Uint64("trace_id",trace_id)
right?
Well, the trace ID is in the span.Context()
, so that becomes:
slog.Uint64("trace_id",span.Context().TraceID())
.
If we’re doing this properly, the trace ID should really be included in the dd
group, so this then becomes:
slog.Group("dd", slog.Uint64("trace_id", span.Context().TraceID()))
.
Clearly we really don’t want to be typing that for all our log statements!
Admittedly, we could get it once at the top of each function and shorten that, but it still doesn’t feel quite right and really increases the overhead for the developer.
Context
The right solution must surely be related to context since we’re injecting spans into the context and passing the context through our code all over the place. Slog even has context based logging methods such as slog.InfoContext(ctx, "message")
.
The slog documentation talks about this and specifically references this problem:
Some handlers may wish to include information from the context.Context that is available at the call site. One example of such information is the identifier for the current span when tracing is enabled.
Some handlers? OK, just not the standard JSONHandler
we’re using. It turns out that some people have already tried to address this issue with their own slog handlers, but like most people, we’re keen to avoid third party packages where possible.
Does that mean we need to write our own handler? Not exactly…
Embedding Structs
In Go, it is possible to embed one struct in another which then “promotes” the original structs fields and methods as if they belonged to your new struct. If this is new to you, Eli Bendersky has some great articles on it.1
So, this means we should be able to embed an existing slog.Handler
and just override the Handle
method to inject our own attributes before passing to the real handler to do any remaining work – winner, we haven’t had to write our own handler!
type ContextHandler struct {
slog.Handler
}
func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
r.AddAttrs(h.addTraceFromContext(ctx)...)
return h.Handler.Handle(ctx, r)
}
func initialiseLogging() *slog.Logger {
jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
ctxHandler := ContextHandler{jsonHandler}
logger := slog.New(ctxHandler)
slog.SetDefault(logger)
return logger
}
On our first attempt, this worked perfectly for our trace id.
However, when we added our other Datadog variables as an slog.Group
it no longer worked:
ddvars := slog.Group("dd",
slog.String("env", cfg.env),
slog.String("service", cfg.serviceName),
slog.String("version", cfg.version),
slog.String("source", "go"),
)
logger = slog.New(ctxHandler).With(ddvars)
Additional Required Methods
It turns out that when you use With()
, slog returns a new handler. This is performed by the WithAttrs
method. From the slog handler guide:
WithAttrs
must return a new handler with the additional attributes, leaving the original handler (its receiver) unchanged
This means that when With()
was being called we were getting back a new JSONHandler
, not a ContextHandler
which doesn’t implement our new Handle
method. ðŸ˜
This is also the same for the WithGroup
method. So we just need to do the same here as we did with our Handle
method, ensuring we return a new ContextHander
having called the original JSONHandler
version:
func (h ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return ContextHandler{h.Handler.WithAttrs(attrs)}
}
func (h ContextHandler) WithGroup(name string) slog.Handler {
return ContextHandler{h.Handler.WithGroup(name)}
}
Adding the Trace ID from Context
All that’s left is for us to implement the addTraceFromContext
method on our ContextHandler
.
Again I spent more time on this than was necessary, trying to identify the context key that was being used using reflection.
The Datadog tracer uses a private field called activeSpanKey
for the key so it’s not possible to get the value using ctx.Value
.
Fortunately though, they do provide a method called SpanFromContext
which does provide what we need:
func (h ContextHandler) addTraceFromContext(ctx context.Context) (as []slog.Attr) {
span, ok := tracer.SpanFromContext(ctx)
if ok {
traceID := span.Context().TraceID()
ddgroup := slog.Group("dd", slog.Uint64("trace_id", traceID))
as = append(as, ddgroup)
}
return
}
And that’s pretty much it. So far it seems to be working just fine.
TL;DR - Show Me The Code
This is the full code to get it working in the end, with initialiseLogging
called from our main
function:
type ContextHandler struct {
slog.Handler
}
func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
r.AddAttrs(h.observe(ctx)...)
return h.Handler.Handle(ctx, r)
}
func (h ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return ContextHandler{h.Handler.WithAttrs(attrs)}
}
func (h ContextHandler) WithGroup(name string) slog.Handler {
return ContextHandler{h.Handler.WithGroup(name)}
}
func (h ContextHandler) addTraceFromContext(ctx context.Context) (as []slog.Attr) {
span, ok := tracer.SpanFromContext(ctx)
if ok {
traceID := span.Context().TraceID()
ddgroup := slog.Group("dd", slog.Uint64("trace_id", traceID))
as = append(as, ddgroup)
}
return
}
func initialiseLogging(cfg config) *slog.Logger {
jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
ctxHandler := ContextHandler{jsonHandler}
ddvars := slog.Group("dd",
slog.String("env", cfg.env),
slog.String("service", cfg.serviceName),
slog.String("version", cfg.version),
slog.String("source", "go"),
)
logger := slog.New(ctxHandler).With(ddvars)
slog.SetDefault(logger)
return logger
}
References
- Datadog - Connect Logs and Traces
- Eli Bendersky - Series on Embedding Structs
- slog - Handler Guide
- Reddit Post - Logging context values using slog
- Stack Overflow - Is there a way to list keys in context.Context
-
Pretty much anything he writes is Gold! ↩︎