# How to propagate a request id through an SDK into server logs

> Mint one id per logical request, send it on every retry as a trace id, a custom header, or baggage, and find that exact id on every line of the server log.

Source: https://voxgig.com/howto/propagate-a-request-id-into-server-logs

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, go
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Mint one id per logical request before the retry loop and attach it to every attempt. Carry it as the trace id in a W3C `traceparent` header when the downstream reads trace context, or as `X-Request-Id` when it does not. A `baggage` member carries it beside a trace. Log the id on the server from whichever header arrived, then search the log for it, and every attempt of the call is there under one id.

---
## You will need

Node 22 or later for the client, Go 1.24 or later for the server, and a log format you control on
the receiving side. Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, `@opentelemetry/api`
1.9.1, and `@opentelemetry/core` 2.12.0. The client uses the two W3C propagators from
`@opentelemetry/core` to write the headers and runs no tracer, so no span is exported anywhere.
The server is the Go standard library: `net/http`, `log/slog`, and two header parsers. No
gateway ran; what Envoy, Kong, and Heroku do with each header is read from their documentation.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A custom `X-Request-Id` header](https://devcenter.heroku.com/articles/http-request-id) | The downstream has no tracing and reads one header you both agree on | A name with no standard behind it, an id a proxy may replace with its own, and a header that stops at the first hop that does not forward it | The downstream runs OpenTelemetry, where the trace id is already the request id |
| [OpenTelemetry Baggage](https://opentelemetry.io/docs/specs/otel/baggage/api/) | A business id that must travel beside the trace without being the trace id, in code that already has the OpenTelemetry API | A header the downstream must parse on purpose, a value that reaches no span or log until code copies it there, and clear text every hop can read | A plain id is enough, or the downstream has no OpenTelemetry at all |
| [W3C Trace Context `traceparent`](https://www.w3.org/TR/trace-context/) | Any service that runs a tracer, and any gateway that already forwards the header | A 32-hex id with no room for your own format, a new span id per attempt to keep straight, and a header a downstream without a tracer never reads | The receiving service reads no trace context and has no tracer to add |

`traceparent` is the standard, and the hops in the path already forward it. The W3C
specification requires a vendor that receives it to send it on, and Kong's OpenTelemetry plugin
injects whatever format came in by default. A custom header works where the downstream has
never heard of tracing. Its price is a name nobody else honors, and an id Envoy replaces at the
edge unless told to keep it. Baggage carries a second id beside the trace, and reaches a span or
a log only when code copies it there.

## Mint the id once and give it a carrier

The mechanism is one line in the right place: the mint sits before the loop, and every attempt
reuses its result. Three carriers can take the id to the server. The W3C propagators from
`@opentelemetry/core` write two of them, and the third is a header name.

```js title="client.mjs"
// Each carrier turns the id into the headers for one attempt.
export const carriers = {
  // The id is the trace id; the span id names the attempt, so a tracer sees one trace with one
  // child span per attempt. A real tracer would mint a random span id; a counter keeps the output stable.
  traceparent: (id, attempt) => {
    const headers = {}
    const spanContext = { traceId: id, spanId: attempt.toString(16).padStart(16, '0'), traceFlags: TraceFlags.SAMPLED }
    traceContext.inject(trace.setSpanContext(ROOT_CONTEXT, spanContext), headers, defaultTextMapSetter)
    return headers
  },
  'x-request-id': (id) => ({ 'x-request-id': id }),
  baggage: (id) => {
    const headers = {}
    const context = propagation.setBaggage(ROOT_CONTEXT, propagation.createBaggage({ 'request.id': { value: id } }))
    baggagePropagator.inject(context, headers, defaultTextMapSetter)
    return headers
  },
}

// Retry only what a retry can fix. This loop retries at once and sends no idempotency key; a POST
// that moves money needs a backoff and a key the provider honors before any retry is safe.
const RETRYABLE = new Set([429, 502, 503, 504])

export async function charge(base, { carrier, mint, attempts = 3 }) {
  if (!Object.hasOwn(carriers, carrier)) throw new TypeError(`unknown carrier: ${carrier}`)
  const id = mint()
  let status
  for (let attempt = 1; attempt <= attempts; attempt++) {
    const res = await fetch(`${base}/v1/charges`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', ...carriers[carrier](id, attempt) },
      body: JSON.stringify({ amount: 1000, currency: 'EUR' }),
    })
    await res.arrayBuffer()
    status = res.status
    if (!RETRYABLE.has(status)) return { id, attempts: attempt, status }
  }
  return { id, attempts, status }
}
```

For `traceparent` the request id is the trace id, 32 lowercase hex characters, and the span id
names the attempt. The specification forbids all zeros in either field and tells a receiver to
ignore an invalid header, so an id that is not hex cannot travel this way.[^1] The custom header
takes any string, within reason. Heroku's router keeps a client's `X-Request-ID` only when it is
20 to 200 characters from a fixed alphabet, and replaces anything else.[^2] Baggage carries
`request.id=<value>` with the value percent-encoded, and the
[OpenTelemetry documentation](https://opentelemetry.io/docs/concepts/signals/baggage/) says a
baggage entry is unassociated with spans, metrics, or logs until code adds it to them.

## Read the id back on the server

The server has one job the client cannot do for it: pick the id out of whichever header arrived
and put it on the log line. In Go that is two parsers and
[`log/slog`](https://pkg.go.dev/log/slog); a service that runs a tracer gets the same parsing
from the OpenTelemetry Go SDK's
[`propagation`](https://pkg.go.dev/go.opentelemetry.io/otel/propagation) package.

```go title="server/main.go"
// requestID picks the id out of the carrier the request used, standard header first.
func requestID(h http.Header) (id, carrier, parent string) {
	if m := traceparent.FindStringSubmatch(strings.TrimSpace(h.Get("traceparent"))); m != nil {
		if m[1] != "ff" && m[2] != strings.Repeat("0", 32) && m[3] != strings.Repeat("0", 16) {
			return m[2], "traceparent", m[3]
		}
	}
	if v := strings.TrimSpace(h.Get("X-Request-Id")); clientID.MatchString(v) {
		return v, "x-request-id", ""
	}
	// baggage: comma-separated members, across every baggage header the request carries,
	// key=value with optional ;properties, values percent-encoded.
	for _, member := range strings.Split(strings.Join(h.Values("baggage"), ","), ",") {
		key, rest, found := strings.Cut(strings.TrimSpace(member), "=")
		if !found || strings.TrimSpace(key) != "request.id" {
			continue
		}
		value, _, _ := strings.Cut(rest, ";")
		if decoded, err := url.PathUnescape(strings.TrimSpace(value)); err == nil && clientID.MatchString(decoded) {
			return decoded, "baggage", ""
		}
	}
	return "", "none", ""
}
```

The order is a policy: the standard header first, the custom one second, baggage last. A
`traceparent` with an all-zero or uppercase id, version `ff`, or a field of the wrong length
counts as absent, which is what the specification tells a receiver to do. An `X-Request-Id` or
baggage id over 200 characters, or with a character outside letters, digits, and `+/=_.:-`,
counts as absent too. A client then cannot write a megabyte or a control character into the log. The JSON handler drops
the time attribute, so two runs print the same bytes and a test can compare them.

```bash
node demo.mjs
```

```text output
carrier traceparent, id 4bf92f3577b34da6a3ce929d0e0e4736
  client: 3 attempts, final status 200
  server log lines carrying that exact id: 3 of 3
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":503,"carrier":"traceparent","request_id":"4bf92f3577b34da6a3ce929d0e0e4736","parent_id":"0000000000000001"}
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":503,"carrier":"traceparent","request_id":"4bf92f3577b34da6a3ce929d0e0e4736","parent_id":"0000000000000002"}
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":200,"carrier":"traceparent","request_id":"4bf92f3577b34da6a3ce929d0e0e4736","parent_id":"0000000000000003"}

carrier x-request-id, id req_01HZX8K3M9Q2V7T4W6Y8A0B2C4
  client: 3 attempts, final status 200
  server log lines carrying that exact id: 3 of 3
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":503,"carrier":"x-request-id","request_id":"req_01HZX8K3M9Q2V7T4W6Y8A0B2C4"}
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":503,"carrier":"x-request-id","request_id":"req_01HZX8K3M9Q2V7T4W6Y8A0B2C4"}
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":200,"carrier":"x-request-id","request_id":"req_01HZX8K3M9Q2V7T4W6Y8A0B2C4"}

carrier baggage, id req_01HZX8K3M9Q2V7T4W6Y8A0B2C4
  client: 3 attempts, final status 200
  server log lines carrying that exact id: 3 of 3
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":503,"carrier":"baggage","request_id":"req_01HZX8K3M9Q2V7T4W6Y8A0B2C4"}
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":503,"carrier":"baggage","request_id":"req_01HZX8K3M9Q2V7T4W6Y8A0B2C4"}
  {"level":"INFO","msg":"request","method":"POST","path":"/v1/charges","status":200,"carrier":"baggage","request_id":"req_01HZX8K3M9Q2V7T4W6Y8A0B2C4"}
```

Each block is one call: the two `503` lines the client retried through and the `200` that ended
it, all under the id the client reported. For `traceparent` the `parent_id` changes with the
attempt while the `request_id` stays, which a tracer would show as one trace with three spans.

Between the SDK and the service the id crosses whatever sits in the path. The W3C specification
requires a vendor that receives `traceparent` to send it on outgoing requests, and names
pass-through proxies as the usual place that happens. Kong's
[OpenTelemetry plugin](https://developer.konghq.com/plugins/opentelemetry/reference/) injects the
format that came in, its `header_type` defaulting to `preserve`, with `w3c` as one option.
Envoy's [header reference](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers)
says it replaces an existing `x-request-id` on edge requests unless `preserve_external_request_id`
is set.[^3]

## Check it worked

Twelve tests run the three carriers through a server that fails twice, and pin the parsing rules
the page relies on. A `traceparent` the specification rejects is logged as no id, and so is an
id too long or outside the alphabet. Baggage survives percent-encoding and a second header. The
standard header wins when two carriers arrive. An id minted inside the loop produces three
distinct ids. The client refuses an unknown carrier and returns a `400` at once.

```js title="propagate.test.mjs"
for (const [carrier, id] of [['traceparent', TRACE], ['x-request-id', ID], ['baggage', ID]]) {
  test(`${carrier}: every attempt of one call reaches the log under the same id`, () =>
    withServer({ failFirst: 2 }, async (server) => {
      const result = await charge(server.base, { carrier, mint: () => id })
      assert.deepEqual([result.attempts, result.status], [3, 200])
      const lines = await server.settled(3)
      assert.equal(lines.length, 3)
      assert.deepEqual(lines.map((line) => line.request_id), [id, id, id])
      assert.deepEqual(lines.map((line) => line.carrier), [carrier, carrier, carrier])
      assert.deepEqual(lines.map((line) => line.status), [503, 503, 200])
    }))
}
```

```bash
node --test propagate.test.mjs
```

```text output
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

`# pass 12` is the line to look for. The carrier tests assert what a support engineer would check
by hand: three lines, one id, and the statuses `503, 503, 200` in order.

## When it goes wrong

The id the client logged appears once in the server log, on the attempt that succeeded, and the
two failures before it carry ids nobody has. The mint moved inside the retry loop, so every
attempt carried a fresh id:

```bash
node pitfall.mjs
```

```text output
client: 3 attempts, final status 200, reported id 3ded7d16-8352-4647-8588-0d55d0b370e0
distinct request ids in the server log: 3
lines carrying the reported id: 1 of 3
  status 503  request_id 66802ea8-d9a6-4cdd-b6a6-cae8c40b704f
  status 503  request_id 1d47dc1c-ef34-4140-822b-a3bf5e29438b
  status 200  request_id 3ded7d16-8352-4647-8588-0d55d0b370e0
```

Move the mint before the loop. The test for it asserts one distinct id per call, which is the
assertion that catches this on the next refactor.

The server logs `carrier: none` for every request although the client sent `traceparent`. The
trace id was not 32 lowercase hex, or the span id was all zeros, so the server ignored the header
as the specification requires. Generate the trace id from 16 random bytes, and never send a zero
span id.

The id arrives at the first service and not at the second. A hop in between dropped or replaced
the header. Envoy replaces an edge `x-request-id` unless configured to preserve it, and a gateway
with no tracing configured may strip `baggage`. Prefer `traceparent`, which gateways forward by
default, and read the hop's documentation for the other two.

## When not to do this

Do not put a customer identifier, an email address, or anything you would not print on a
billboard into `baggage` or a custom header. Both travel in clear text to every hop and every
third-party API the request reaches, which the OpenTelemetry documentation warns about in as
many words.

Do not mint a new trace id per attempt because a tracer handed you one. The attempt is a span and
the call is the trace, so a retry that starts a new trace looks exactly like the three unrelated
requests in the pitfall.

Do not invent `X-Correlation-Id` when the downstream already reads `X-Request-Id`, or either when
it reads `traceparent`. The id has to be read by the side writing the log, and a header nobody
parses never reaches a log line.

Do not rely on a custom header crossing a hop you do not control. The W3C header has a
forwarding rule written down, while `X-Request-Id` has whatever the proxy in front of you
decided.[^4]

## Related how-tos

- [Tag SDK calls with session and request ids](/howto/tag-sdk-calls-with-session-and-request-ids)

- [Choose the layer that instruments outbound API calls](/howto/choose-a-layer-to-instrument-outbound-calls)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, `@opentelemetry/api` 1.9.1, and
`@opentelemetry/core` 2.12.0. Every output block is what the command preceding it printed. The
server ran as a Go process on 127.0.0.1, started by the Node demo. No gateway ran; what Envoy,
Kong, and Heroku do with each header is read from their documentation.

[^1]: [Trace Context](https://www.w3.org/TR/trace-context/) became a W3C Recommendation on
November 23, 2021. It gives the trace id as 32 lowercase hex digits and the parent id as 16, with
all zeros forbidden in both. When the trace id is invalid, vendors "MUST ignore the
`traceparent`" and start a trace of their own. It also settles the question request-id schemes
leave open, because a vendor receiving a `traceparent` request header "MUST send it to outgoing
requests," which makes a gateway's forwarding a matter of conformance.

[^2]: Heroku's [request id page](https://devcenter.heroku.com/articles/http-request-id) accepts
a client's `X-Request-ID` when it is 20 to 200 characters of ASCII letters, digits, and the four
symbols `+`, `/`, `=`, and `-`. Invalid ids "will be ignored and replaced with generated ones,"
it adds, and the id then appears in the router log as `request_id`. A UUID is 36 characters of
that alphabet and passes. The id on this page would be replaced for the underscore in `req_`, and
a short counter for its length, each without notice.

[^3]: Envoy's [header reference](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers)
says it generates an `x-request-id` for external origin requests and replaces an existing one on
edge requests by default. Because of its out-of-process architecture, it adds, "the header can
not be automatically forwarded by Envoy itself," and leaves that job to the caller. It calls that
"one of the few areas where a thin client library is needed," so the proxy that assigns the id
cannot carry it to the next hop.

[^4]: The prefix itself was deprecated by [RFC 6648](https://www.rfc-editor.org/rfc/rfc6648.html)
in June 2012, a Best Current Practice. It found that the convention "causes more problems than it
solves," and asked designers of new parameters to stop using it. The document's complaint is
that a prefix meant to mark a name as provisional ended up on names that stayed in use for
decades, so the mark meant nothing. `X-Request-Id` predates the advice and is in wider use than
most headers that followed it.