A customer says a call failed at about nine this morning and you have four million log lines. The requests carry no identifier you can search on, your client retried three times, and the service recorded three separate failures. Answering the question means guessing which three lines out of the four million belong together.
What you get
You will end up with a client that sends three identifiers with three different lifetimes, and a user agent that says which product and version made the call. A support question then becomes a lookup. This is for you if you publish a client and answer questions about what it did.
Short answer
Send a session id that lives as long as the client instance, a request id that identifies one logical call, and an attempt number that separates the retries of that call. Add a user agent naming the product and its version. Keep the request id the same across retries, because a fresh one per attempt makes three failures look like three different problems.
You will need
Node 22 or later, and a client whose outgoing headers you control. These identifiers belong to your product rather than to the transport, and they sit beside W3C trace context rather than replacing it.1 The user agent format is the ordinary one, and worth following so existing log tooling parses it.2
Voxgig maintains sdkgen. This page compares its clienttrack feature with a hand-written interceptor, the platform headers Stainless emits, and trace context.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written interceptor | Any client, and you want the header names to be yours | Code in every client you publish, and a convention to keep in step | A generator already applies one consistently |
| Stainless platform headers | You generate with Stainless and want its conventions out of the box | Header names chosen by the generator, which your service has to accept | Your service already has header names in its documentation |
| W3C trace context | You run distributed tracing and want the call to join an existing trace | A tracing system, and ids that mean nothing without it | The question is which customer call failed, answered from logs |
| sdkgen clienttrack feature | You generate several languages and want the same headers in all of them | An opt-in feature, and a model to regenerate when the headers change | You publish one client and can write ten lines |
Trace context and these identifiers answer different questions and the pair is stronger than either. A trace id follows one request through your services; a session id groups everything one client instance ever did. Support questions are usually about the second, and observability dashboards are usually about the first.
Emitting both costs four headers. What it buys is that “this customer’s integration started failing after they upgraded” becomes a query over the session ids and user agents you already collected.
Give each identifier its own lifetime
Three scopes, and the boundaries are the design.
async call(path) {
const requestId = ids()
const seen = []
for (let attempt = 1; attempt <= attempts; attempt++) {
const res = await fetchImpl(`${baseUrl}${path}`, {
headers: {
'user-agent': userAgent,
'x-session-id': sessionId,
'x-request-id': requestId,
'x-attempt': String(attempt),
},
})
The request id is created once, outside the retry loop. That single placement is what makes three failed attempts one incident rather than three, and it is the detail most hand-written interceptors get wrong.
Put the attempt number in its own header rather than folding it into the id. A service can then count retries without parsing anything, and an alert on attempts over one finds a client retrying into a wall.
Make the user agent say something
A product name and a version, in the ordinary format.
const userAgent = `${product}/${version} (node)`
A user agent of node tells you nothing when a release breaks. One carrying the package name and
version lets you answer which versions are still in use, which customers upgraded, and whether the
failures started at a version boundary.
Echo the request id back in the response. A caller that logs what the service confirmed can be matched against the service’s own records without asking anybody for a timestamp.
Check it worked
Make two calls, with the first one failing twice before it succeeds.
node demo.mjs
user agent @meterco/sdk/1.5.0 (node)
session ses_7c4
/meters 200 request req_1 attempts 1:503 2:503 3:200
/invoices 200 request req_2 attempts 1:200
what the service recorded
path session request attempt user-agent
/meters ses_7c4 req_1 1 @meterco/sdk/1.5.0 (node)
/meters ses_7c4 req_1 2 @meterco/sdk/1.5.0 (node)
/meters ses_7c4 req_1 3 @meterco/sdk/1.5.0 (node)
/invoices ses_7c4 req_2 1 @meterco/sdk/1.5.0 (node)
Read the service’s table. The four requests share one session and two request ids, and the
attempt column marks which of them were retries. A support engineer with req_1 finds three lines and can see the call
eventually succeeded, which is a different answer from three unrelated failures.
The user agent column is the one that pays off later. When a release goes wrong, the question is
which versions are affected. A column full of node cannot answer that, and a column full of
package names and versions can.
The session id is what connects the two calls. Without it, any log query sees two unrelated callers rather than one client making two calls.
node --test client.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 298.729032
When it goes wrong
The ids never reach the service. A proxy strips unknown headers. Use a prefix the infrastructure allows, and check at the service rather than trusting the client. One assertion in an integration test is cheaper than the incident where the headers were never arriving.
Every attempt has a different request id. The id is generated inside the retry loop. Move it out, and assert in a test that all attempts of one call share it.
The session id identifies a person. Somebody reused a user id. Keep it opaque and unrelated to identity, because it ends up in logs on both sides, and in whatever sits between them.
Two clients in one process share a session. The id was generated at module scope. Generate it per client instance, so two configurations are two sessions. That also makes a connection pool per client visible in the logs, which is useful the day one of them misbehaves.
When not to do this
Do not put anything sensitive in these headers. They are logged by you, by the service, and by everything in between, and a customer name in a header is a customer name in a log retention policy.
Do not invent header names when your service already documents some. The value is in one convention,
and a client that sends x-request-id to a service reading x-correlation-id has sent nothing.3
Do not treat the sdkgen clienttrack feature as a substitute for deciding the names. It applies the headers consistently across the languages it generates, and which names your service reads and logs is still yours to settle. Write them into the API documentation, once, and let every client follow that.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
Trace Context is a W3C Recommendation dated November 23, 2021. Its
traceparentheader is four fields joined by hyphens: a two-digit version, a 32-digit trace id, a 16-digit parent id and two digits of flags, all lowercase hex, for a fixed 55 characters. The document assumes version00and forbidsff. A trace id of all zeros is invalid, and so is a parent id of all zeros. The specification spends a sentence on each of the two values nobody would have chosen. ↩︎ Back to text -
RFC 9110 gives
CERN-LineMode/2.15 libwww/2.17b3as its example of a user agent. It encourages implementations not to use the product tokens of other implementations to declare compatibility, since that circumvents the purpose of the field. MDN records that almost every browser today sendsMozilla/5.0, for historical reasons. The two documents describe the same field. ↩︎ Back to text -
The
x-on the custom headers in this page has been deprecated since June 2012. That is when RFC 6648 found that marking parameters not yet standardized with anX-prefix causes more problems than it solves. It is a Best Current Practice, BCP 178, and it deprecates the prefix for newly defined parameters only, which leaves every existingX-header exactly where it was. ↩︎ Back to text