# How to tag SDK calls with session and request ids

> Send three identifiers with three lifetimes, so a support question about one call can be answered from the service's own logs.

Source: https://voxgig.com/howto/tag-sdk-calls-with-session-and-request-ids

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://www.w3.org/TR/trace-context/) rather than replacing it.[^1] The [user agent
format](https://www.rfc-editor.org/rfc/rfc9110.html#name-user-agent) 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](https://undici.nodejs.org/#/docs/api/Dispatcher) | 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](https://www.stainless.com/docs) | 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](https://www.w3.org/TR/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](https://github.com/voxgig/sdkgen) | 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.

```js title="client.mjs"
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.

```js title="client.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test client.test.mjs
```

```text output
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

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

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: [Trace Context](https://www.w3.org/TR/trace-context/) is a W3C Recommendation dated
November 23, 2021. Its `traceparent` header 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 version `00` and forbids `ff`. 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.

[^2]: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-10.1.5) gives
`CERN-LineMode/2.15 libwww/2.17b3` as 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent) records
that almost every browser today sends `Mozilla/5.0`, for historical reasons. The two documents
describe the same field.

[^3]: The `x-` on the custom headers in this page has been deprecated since June 2012. That is when
[RFC 6648](https://www.rfc-editor.org/rfc/rfc6648) found that marking parameters not yet
standardized with an `X-` 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 existing
`X-` header exactly where it was.