Microservices Logging: Follow One Request Across Services
Last verified on Nov 8, 2026.
In a microservices system, one user request crosses many processes, and each writes its own log. To debug it you need a way to pull every line for that request back together. The method is the same everywhere: carry one identifier across every call, write it into every log line, use one schema across services, and send everything to one searchable place. This guide shows each step, including an example that was run and that exposes a common propagation mistake.
Carry One ID Across Every Call
The standard identifier is the trace ID from W3C Trace Context. It travels in the traceparent HTTP header, which has four fields: a version, the trace ID, the ID of the calling span, and trace flags. The spec's example is:
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01The second field is the trace ID, shared by every span in the request, which is what makes it useful as a correlation ID in logs. The first service that sees a request without one starts a new trace, and every service after that must pass it on. With an OpenTelemetry SDK or auto-instrumentation, the HTTP client and server libraries inject and extract this header for you. See distributed tracing for the concepts.
Where you cannot run tracing, generate a request ID at the edge and forward it as a header. nginx's $request_id is a 16-byte random value that you can pass with proxy_set_header X-Request-ID $request_id;, as shown in the nginx logs guide. OWASP calls this an interaction identifier: record it, rather than forcing later analysis to reconstruct which events belonged together.
Put the ID in Every Log Line
Propagation gets the ID into each service. Logging has to write it out. With the OpenTelemetry logs bridge, the SDK adds the active trace and span IDs to each record, as the OpenTelemetry docs describe. Without a bridge, add them yourself. This Python example uses a logging filter that reads the current span:
class TraceFilter(logging.Filter):
def __init__(self, service):
super().__init__(); self.service = service
def filter(self, record):
ctx = trace.get_current_span().get_span_context()
record.service = self.service
record.trace_id = format(ctx.trace_id, "032x") if ctx.is_valid else None
record.span_id = format(ctx.span_id, "016x") if ctx.is_valid else None
return TrueTwo small services run in one process show it: orders-api calls payments-api over HTTP, and the orders side injected the header with propagate.inject. The outgoing header was 00-17b4193f4a29ec7d001b34d09ac5faef-6173bbb4c22affe8-03, and the logs from both services read:
{"level": "INFO", "service": "orders-api", "message": "order received", "trace_id": "17b4193f4a29ec7d001b34d09ac5faef", "span_id": "6173bbb4c22affe8"}
{"level": "ERROR", "service": "payments-api", "message": "card declined", "trace_id": "17b4193f4a29ec7d001b34d09ac5faef", "span_id": "ca2d1271a24acb40"}
{"level": "WARNING", "service": "orders-api", "message": "payment call failed: HTTP Error 402: Payment Required", "trace_id": "17b4193f4a29ec7d001b34d09ac5faef", "span_id": "6173bbb4c22affe8"}One trace_id links all three lines across two services. The span_id differs per service, which tells you which hop wrote the line.
The Header Case Bug
The first version of this test failed silently. The server extracted context with propagate.extract(dict(self.headers)), and the payments-api line came out with a different trace ID than the caller. Python's HTTP server stores the header as Traceparent, and a plain dict lookup for traceparent is case sensitive and finds nothing, so the service started a new trace. Passing the case-insensitive self.headers object fixed it. The W3C spec says vendors must expect the header name in any case, so your server code has to read it that way. When logs in a downstream service carry a different trace ID, check header extraction first, then any proxy or gateway in between that might drop or rename the header.
Use One Schema Across Services
A trace ID only helps if every service puts it in the same field. Agree on a small shared schema and enforce it in a shared logging module or a collector rule:
The OpenTelemetry log data model has the same ideas built in: TraceId and SpanId on each record, and a Resource for the service and host that stays the same for a source. If you adopt it, you get this schema without inventing one. See the JSON logging guide for writing each field, and the field drift guide for catching services that diverge.
Collect Logs in One Place
Each service writes to standard output and an agent ships it. Kubernetes describes the pattern: containers write to stdout and stderr with no agreed format, and a node-level agent collects those logs and forwards them for aggregation. One agent per node means application code carries no shipping logic and no log files to rotate. The OpenTelemetry Collector, Fluent Bit, and Vector all fit this role. You can read a single pod's logs with the kubectl logs guide, but that does not scale across services, which is the point of log aggregation.
Follow a Request
With centralized JSON logs, tracing a failure is a filter on one field. From a log export:
jq -c 'select(.trace_id == "17b4193f4a29ec7d001b34d09ac5faef")' all-services.log | jq -r '[.service, .level, .message] | @tsv'Read the lines by timestamp, from the first service to the last, and the error closest to the root cause is usually the earliest ERROR in that set. If lines appear out of order, the cause may be clock differences between hosts. OWASP advises synchronizing time across servers, and the clock skew guide covers spotting drift.
Pitfalls to Watch
- Lost context in async work. A message on a queue or a background job does not carry HTTP headers. Copy the trace context into the message metadata and extract it in the consumer.
- Services outside tracing. A legacy service that ignores
traceparentbreaks the chain. At minimum, have it log the incoming header value and forward it. - Different levels per service. If one service logs at
debugand another atwarn, a trace looks lopsided. Set levels from a shared config, and see logging best practices. - Sampling that splits a trace. Sampling by line drops some of the lines of one request. Sample by trace ID so a request is kept or dropped as a whole, as the log sampling guide explains.
- High-cardinality field names. Do not use IDs as keys. Put them in values.
Sending Microservice Logs to Sazabi
Sazabi, an AI-native observability and security platform, stores logs and traces as searchable records, and its docs say the agent can correlate traces with logs by trace context while structured log fields preserve context. That means the trace_id field you write above is what links a log line to its trace in an investigation. Send logs from each service through one OpenTelemetry Collector or the OpenTelemetry integration, keep service consistent, and ask about one request in plain language, for example sazabi logs ask "show all lines with trace_id 17b4193f4a29ec7d001b34d09ac5faef". The OpenTelemetry logs guide shows the SDK and Collector settings.
Frequently Asked Questions
How do I trace a request across microservices in logs?
Generate or accept a trace ID at the first service, pass it to every downstream call in a traceparent header, and write it into every log line as a field. Then search for that one ID in the central log store.
What is the difference between a correlation ID and a trace ID?
A correlation ID is any identifier you attach to related events. A trace ID is the standard form of it, defined by W3C Trace Context, and tracing libraries propagate it for you. Use the trace ID when you run tracing, and a request ID at the edge when you do not.
Should each microservice write its own log files?
In containers, write to standard output and let a node-level agent collect and forward it, as Kubernetes describes. A shared logging agent means services do not each need their own rotation and shipping.
Why do my downstream services log a different trace ID?
The context was not extracted from the incoming request. Common causes are a propagator that does not read the traceparent header, a header lookup that is case sensitive, or a proxy that strips the header.
Sources
- The traceparent HTTP header has four fields (version, trace-id, parent-id, trace-flags) and vendors MUST accept the header name in any case (accessed Nov 8, 2026)
- With an SDK or auto-instrumentation active, OpenTelemetry correlates existing logs with the active trace and span by adding their IDs (accessed Nov 8, 2026)
- A log record carries TraceId, SpanId, and Resource fields (accessed Nov 8, 2026)
- Containers write to stdout and stderr with no agreed format, and a node-level agent collects the logs and forwards them for aggregation (accessed Nov 8, 2026)
- An interaction identifier links all events for one user interaction, and should be recorded instead of forcing later correlation to reconstruct it; time should be synchronized across servers (accessed Nov 8, 2026)
- Sazabi stores logs and traces as searchable records the agent can correlate by trace context, and structured log fields preserve context (accessed Nov 8, 2026)