Skip to content
Development

Trace IDs across microservices, without threading them through every function

Adarsh Singh·15 September 2026·6 minutes

If you've worked on a service-oriented backend, you've hit this problem: a request comes in, gets a trace ID, and then that ID needs to show up in every log line and every downstream call the request touches, no matter how many functions or services it passes through on the way. The two usual options are both bad. Pass the ID as a parameter through every function signature between the entry point and wherever you need it, polluting business logic with a concern it has nothing to do with. Or store it somewhere global and hope two concurrent requests don't step on each other.

@smoke-trees/smoke-context is the middleware we built to avoid both. It's a small Express library, built on Node's AsyncLocalStorage, that keeps a trace ID and a set of custom context values attached to a request as it moves through async code, so any function in the chain can read it without receiving it as an argument.

The basic version

At its simplest, Context() is a middleware that reads a trace ID off an incoming header, generates one if it's missing, and attaches it to req.context:

typescript
import Context from '@smoke-trees/smoke-context';

app.use(Context({ headerName: 'X-Trace-ID' }));

That's enough if all you need is req.context.traceId inside a route handler. But the moment the trace ID needs to reach a function three layers deep, one that doesn't have access to req, this version runs out of road.

Where AsyncLocalStorage comes in

That's what ContextProvider is for. Instead of attaching context to the request object, it runs your request inside an AsyncLocalStorage context, which means any function anywhere in that request's async chain can call ContextProvider.getContext() and get the right value back, even with no reference to req at all:

typescript
import express from 'express';
import { ContextProvider } from '@smoke-trees/smoke-context';

const app = express();

app.use(
  ContextProvider.getMiddleware({
    headerName: 'X-Trace-ID',
    extractKeyValuePairs: (req) => ({
      userId: req.user?.id,
      requestPath: req.path,
    }),
  }),
);

Internally, the middleware pulls the trace ID from the header (or generates one with uuid.v4 if it's absent), builds a context object, and runs the rest of the request inside asyncLocalStorage.run(context, next). It also echoes the trace ID back on the response as x-context-id, which is a small thing that saves a lot of time when you're correlating a request from the client side.

From that point on, anywhere in the request's lifecycle:

typescript
function log(message: string) {
  const context = ContextProvider.getContext();
  console.log(`[${context?.traceId}] ${message}`);
}

No parameter threading. The context follows the async chain, not the function signature.

The part that matters for microservices

A trace ID that stays inside one service isn't that useful on its own. The real value shows up when a request fans out across multiple services and you still want one ID tying the whole thing together in your logs. That's what the bundled fetch wrapper is for:

typescript
import { fetch } from '@smoke-trees/smoke-context';

async function callDownstream() {
  const result = await fetch('http://downstream-service/api');
  return result.json();
}

This isn't a new HTTP client, it's a thin wrapper around node-fetch. Before the request goes out, it checks for a context, either the one passed explicitly as the second argument or the one currently active in AsyncLocalStorage, and injects the trace ID as a header on the outgoing request, using whatever header name the context was configured with. The downstream service picks that header up with its own Context or ContextProvider middleware, and the same trace ID carries forward into its logs too. String enough of these together and a single trace ID spans an entire call graph, not just one process.

Where logging gets easier

The part of this that ends up saving the most time day to day isn't tracing itself, it's what it does to logging. Once a trace ID lives in AsyncLocalStorage for the duration of a request, a logger can reach in and grab it without every call site having to pass it along.

That's exactly how we wired it up in `@smoke-trees/postgres-backend`, our backend core library. The logger is a Winston instance with a custom format that pulls the trace ID straight out of ContextProvider before anything gets written:

typescript
import { ContextProvider } from "@smoke-trees/smoke-context";
import { createLogger, format, transports } from "winston";

const contextFormat = format((info) => {
  const context = ContextProvider.getContext();
  info.traceId = context?.traceId;
  return info;
});

const logger = createLogger({
  transports: [
    new transports.Console({
      format: format.combine(contextFormat(), format.timestamp(), format.json()),
    }),
  ],
});

The call site stays boring on purpose:

typescript
log.info("Order created", "createOrder", { orderId: order.id });
log.error("Payment capture failed", "captureOrder", error);

No trace ID in that call. It doesn't need to be there. contextFormat attaches it automatically, which means every log line written anywhere during that request, from the route handler down to whatever database call failed three layers in, carries the same ID without a single function in that path having to know tracing exists. Grep the trace ID and you get the full story of one request in order, across every log statement it touched.

The same idea extends to request-level logging. StLoggerMiddleware sits on the response's finish event, pulls the trace ID (and, if it's in the context values, the user ID) off ContextProvider, and builds a structured record of the request: method, URL, status, headers, response size. On failed requests, that record gets persisted to the database instead of just the console, so an incident doesn't mean grepping console output across five services, it means querying one table for a trace ID and seeing every service that touched it and what each one returned. A circuit breaker in the same middleware turns that persistence off automatically if failures spike too fast, so a bad deploy doesn't also take down the logging table under write pressure.

None of that logging code is doing anything clever with tracing itself. It's just reading a value that's already there. That's the actual payoff of building context propagation on AsyncLocalStorage instead of passing IDs by hand: every other piece of infrastructure that wants the trace ID, loggers, error handlers, metrics, can reach for it the same simple way, instead of every team reinventing how to plumb it through.

Why this shape and not something heavier

There are more complete tracing solutions out there, and if you need span-level detail across a large system, OpenTelemetry is the right tool. Smoke Context solves a narrower problem: get a trace ID (and a small bag of custom values, whatever extractKeyValuePairs decides matters, like a user ID or a request path) to follow a request through async code and across service boundaries, with a setup that's a single middleware line and a wrapped fetch call. No collector to run, no schema to agree on across teams, no dependency beyond Express itself.

That tradeoff won't fit every system. It's fit ours well enough that it's been running across our services since early versions, currently at 1.6.0, MIT licensed, with express >= 4.0.0 as its only real peer dependency.

Using it

bash
npm install @smoke-trees/smoke-context
typescript
import express from 'express';
import { ContextProvider, fetch } from '@smoke-trees/smoke-context';

const app = express();

app.use(ContextProvider.getMiddleware({ headerName: 'X-Trace-ID' }));

app.get('/orders/:id', async (req, res) => {
  const order = await fetchOrder(req.params.id);
  res.json(order);
});

async function fetchOrder(id: string) {
  const context = ContextProvider.getContext();
  console.log(`[${context?.traceId}] fetching order ${id}`);
  const response = await fetch(`http://orders-service/orders/${id}`);
  return response.json();
}

The source is on GitHub at smoke-trees/smoke-context, and the logging setup described above lives in smoke-trees/node-postgres-backend-core. If you're debugging the same kind of "where did this request even go" problem across your own services, both are small enough to read end to end in under half an hour.

© 2026 SMOKETREES DIGITAL LLP. ALL RIGHTS RESERVED.