Trace-Correlated Logs
[!IMPORTANT] Logs without trace IDs are just noise. Logs with trace IDs are a precision instrument.
Imagine a production outage. You have thousands of error logs flooding your console.
Payment failed, Database timeout, NullPointerException.
But which error belongs to which user request? Are they related?
Without trace correlation, you are left grepping by timestamps and praying for a match. With Trace-Correlated Logs, every single log line carries the DNA of the transaction it belongs to: the trace_id and span_id.
1. The Power of Correlation
When you connect logs to traces, you unlock the ability to:
- Jump from Log to Trace: See the full journey of a request that generated an error.
- Filter by Trace: Isolate all logs for a specific transaction across all microservices.
- Contextualize Errors: Understand why a log event happened based on the span’s attributes.
Interactive: The Correlation Simulator
Toggle the switch below to see the difference between standard logging and trace-correlated logging.
Application Logs
Distributed Trace (Waterfall)
2. How It Works: The MDC Bridge
The magic happens via the Mapped Diagnostic Context (MDC) in Java or Context propagation in Go. The OpenTelemetry agent or SDK intercepts the current span context and injects it into the logging framework’s thread-local storage.
- Request Starts: OTel creates a Span with
trace_id. - Context Injection: OTel puts
trace_idandspan_idinto MDC. - Log Event: Logger reads from MDC and formats the string.
- Output: Log line contains the IDs.
3. Implementation Guide
1. Java (Logback + Spring Boot)
In Java, the OpenTelemetry Java Agent handles this automatically. You just need to configure your logging pattern.
Dependency (if using Spring Boot 3+):
No extra dependency needed for the agent, but ensure you have logback-classic.
src/main/resources/logback-spring.xml:
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- %X{trace_id} and %X{span_id} are populated by OTel Agent -->
<pattern>
%d{ISO8601} [%thread] %-5level %logger{36} - trace_id=%X{trace_id} span_id=%X{span_id} - %msg%n
</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
[!TIP] If you are not using the Java Agent, you must manually instrument using
opentelemetry-logback-mdc-1.0.
2. Go (Slog)
In Go, context must be passed explicitly. We use a “Bridge” to connect OTel to slog.
Dependencies:
go get go.opentelemetry.io/contrib/bridges/otelslog
go get go.opentelemetry.io/otel
Implementation:
package main
import (
"context"
"os"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"log/slog"
)
func main() {
// 1. Create a logger that speaks OTel
logger := otelslog.NewLogger("my-service")
// 2. Set as default (optional)
slog.SetDefault(logger)
ctx := context.Background()
tracer := otel.Tracer("my-tracer")
// 3. Start a span
ctx, span := tracer.Start(ctx, "process-order")
defer span.End()
// 4. Log with Context!
// The logger extracts the trace_id from 'ctx'
logger.InfoContext(ctx, "Processing payment",
"amount", 99.00,
"currency", "USD",
)
// Error logging with span recording
// span.RecordError(err) // Don't forget this for traces!
}
[!NOTE] In Go, you must use
InfoContext,ErrorContext, etc., and pass thectxobject. Standardlogger.Infowill NOT capture the trace ID because Go has no ThreadLocal equivalent.
4. Structured Logging (JSON)
Text logs are fine for humans, but machines prefer JSON. When sending logs to ELK (Elasticsearch), Loki, or Datadog, use JSON to avoid expensive parsing rules.
Java (Logstash Encoder)
Add dependency:
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>
Logback Config:
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
</encoder>
</appender>
Go (Slog JSON)
Go’s slog supports JSON natively.
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)
// Middleware or helper needed to inject trace_id into standard JSON handler
// Or stick with otelslog which handles it automatically.
5. Best Practices
1. Context, Context, Context
Don’t just log “Error”. Log attributes.
// BAD
slog.Error("Database failed")
// GOOD
slog.ErrorContext(ctx, "Database query failed",
"query.id", "q_123",
"retry_count", 3,
"duration_ms", 150,
)
2. Avoid High Cardinality in Messages
The log message should be static. The attributes should contain variable data.
- ❌
log.info("User jules_123 logged in")→ Creates millions of unique message patterns. - ✅
log.info("User logged in", "user_id", "jules_123")→ One message pattern, easy to aggregate.
3. Sampling Considerations
If you sample traces (e.g., keep 10%), what happens to logs?
- Head Sampling: If the trace is dropped, the
trace_idstill exists in the log, but looking it up in Jaeger will return 404. - Recommendation: Log everything (if affordable) or use tail-sampling in the Collector to keep logs for failed requests even if the trace was going to be dropped.
6. Summary
Trace-correlated logs are the bridge between the “what” (Logs) and the “where/when” (Traces). By ensuring every log line carries a trace_id, you transform your debugging process from a guessing game into a precision investigation.
Next, we will look at how to review what we’ve learned.