Log correlation
Last updated:
Spans and metrics tell you that something failed; the log line tells you why. To get from one to the other, every log line a handler writes should carry the same three envelope fields. They are already on the message, so no new wire field is involved.
| Log field | Envelope field | What it lets you do |
|---|---|---|
trace_id |
trace_id |
find every line from every hop of one causal chain, across services and languages (GR-4) |
message_id |
meta.id |
find the lines for one specific message, including its retries |
job |
job (the URN) |
filter by message type |
The field names are a recommendation, not part of the contract. message_id is used rather
than id or message because several logging libraries reserve message / msg for the log
text.
How this lines up with traces. For a UUID trace_id, the OpenTelemetry TraceID is the
same 16 bytes, so it is the UUID’s 32 hex digits without hyphens
(the mapping).
A trace_id that is not a UUID is hashed for the TraceID but logged as it arrived, so search
logs on the raw value and use the SDK’s traceIdOf / TraceIDOf helper when you need the
hashed TraceID. See
a trace_id that is not a UUID.
meta.id is a different thing from trace_id: one message versus one chain
(id vs trace_id).
Keep the payload out of the context. data can carry personal data (see
GDPR field encryption), so log the three identifiers
above and the specific values you need, not the whole data object.
Each example wraps a handler, so every line the handler logs carries the context. The
logging libraries (slog, logging, pino, SLF4J, Microsoft.Extensions.Logging, Monolog) are
the ones you already use; only the BabelQueue envelope accessors come from the SDK.
Go: slog
The handler receives a babelqueue.Envelope; env.TraceID, env.Meta.ID and env.URN() are
the three fields.
import (
"context"
"log/slog"
babelqueue "github.com/babelqueue/babelqueue-go"
)
func withLogContext(next babelqueue.Handler) babelqueue.Handler {
return func(ctx context.Context, env babelqueue.Envelope) error {
log := slog.With("trace_id", env.TraceID, "message_id", env.Meta.ID, "job", env.URN())
log.InfoContext(ctx, "handling message")
return next(ctx, env)
}
}
app.Handle("urn:babel:orders:created", withLogContext(handleOrderCreated))
Pass log on (or store it in ctx) if the code below the handler logs too.
Python: LoggerAdapter
A handler that takes a third argument receives the full envelope (data, meta, envelope);
meta is the meta block.
import logging
logger = logging.getLogger("orders")
def on_order_created(data, meta, envelope):
log = logging.LoggerAdapter(logger, {
"trace_id": envelope["trace_id"],
"message_id": meta.get("id"),
"job": envelope["job"],
})
log.info("handling order %s", data["order_id"])
app.register("urn:babel:orders:created", on_order_created)
The adapter’s extra values become attributes on the record, so a formatter can print them:
%(trace_id)s %(message_id)s %(job)s.
Node: pino child logger
Envelope is exported by @babelqueue/core: trace_id, meta.id and job are plain
properties.
import pino from "pino";
import type { Envelope } from "@babelqueue/core";
const logger = pino();
async function onOrderCreated(env: Envelope): Promise<void> {
const log = logger.child({
trace_id: env.trace_id,
message_id: env.meta.id,
job: env.job,
});
log.info("handling order");
}
Java: SLF4J MDC
Envelope is a record: traceId(), meta().id() and job(). MDC.putCloseable removes each
key again when the block ends, so the context does not leak to the next message on the thread.
import com.babelqueue.idempotency.Handler;
import org.slf4j.MDC;
static Handler withLogContext(Handler next) {
return envelope -> {
try (MDC.MDCCloseable t = MDC.putCloseable("trace_id", envelope.traceId());
MDC.MDCCloseable m = MDC.putCloseable("message_id", envelope.meta().id());
MDC.MDCCloseable j = MDC.putCloseable("job", envelope.job())) {
next.handle(envelope);
}
};
}
Add %X{trace_id} %X{message_id} %X{job} to your log pattern (Logback and Log4j2 both read the
MDC).
.NET: ILogger scope
Handler is Task Handler(Envelope envelope); the properties are TraceId, Meta?.Id and
Job.
using BabelQueue;
using Microsoft.Extensions.Logging;
static Handler WithLogScope(ILogger logger, Handler next) => async envelope =>
{
using IDisposable? scope = logger.BeginScope(new Dictionary<string, object?>
{
["trace_id"] = envelope.TraceId,
["message_id"] = envelope.Meta?.Id,
["job"] = envelope.Job,
});
await next(envelope);
};
Scopes show up in the output only if the logger provider is configured to include them (for
the console provider, IncludeScopes = true).
PHP: Monolog processor
The php-sdk hands a handler a ConsumedMessage: getTraceId(), getUrn() and getMeta()
(the meta block as an array). This processor uses the Monolog 3 LogRecord API.
use BabelQueue\Contracts\ConsumedMessage;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
final class BabelQueueContext implements ProcessorInterface
{
public function __construct(private readonly ConsumedMessage $message)
{
}
public function __invoke(LogRecord $record): LogRecord
{
return $record->with(extra: $record->extra + [
'trace_id' => $this->message->getTraceId(),
'message_id' => $this->message->getMeta()['id'] ?? null,
'job' => $this->message->getUrn(),
]);
}
}
$handler = function (ConsumedMessage $message) use ($logger): void {
$logger->pushProcessor(new BabelQueueContext($message));
try {
$logger->info('handling order');
} finally {
$logger->popProcessor();
}
};
Per-SDK pages
Each SDK’s Reliability & governance page links here from its OpenTelemetry section: Go, Python, Node, Java, .NET, PHP.
Back to Observability.