How-to › Operate integrations in production

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.

Audience
API consumer
Level
intermediate
Topic
Instrument and meter API calls
Languages
TypeScript and Go
Verified

A customer reports a failed payment with a timestamp, and the service log for that minute holds four hundred lines. The SDK that made the call retried twice, so one failure is three requests, none of which carries the id in the client’s own log. Support has a time and a customer, but no line to point at.

What you get

You will end up with a client that mints one id per call and sends it on every retry, and a Go server that logs it from three headers. This is for you if a ticket names a request your logs cannot find.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A custom X-Request-Id headerThe downstream has no tracing and reads one header you both agree onA 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 itThe downstream runs OpenTelemetry, where the trace id is already the request id
OpenTelemetry BaggageA business id that must travel beside the trace without being the trace id, in code that already has the OpenTelemetry APIA 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 readA plain id is enough, or the downstream has no OpenTelemetry at all
W3C Trace Context traceparentAny service that runs a tracer, and any gateway that already forwards the headerA 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 readsThe 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.

// 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 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; a service that runs a tracer gets the same parsing from the OpenTelemetry Go SDK’s propagation package.

// 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.

node demo.mjs
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 injects the format that came in, its header_type defaulting to preserve, with w3c as one option. Envoy’s header reference 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.

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])
    }))
}
node --test propagate.test.mjs
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:

node pitfall.mjs
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

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.

Footnotes

  1. 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. ↩︎ Back to text

  2. Heroku’s request id page 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. ↩︎ Back to text

  3. Envoy’s header reference 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. ↩︎ Back to text

  4. The prefix itself was deprecated by RFC 6648 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.