Aviary

Cross-Process

Carry the context across queue and durable boundaries with serialize() and deserialize() — the hard part that justifies the library.

This is the page that justifies the library. Reading the current user inside a single request is convenient; carrying that context across a process or queue boundary — so the BullMQ worker that processes a job knows who enqueued it, and the durable workflow step knows which tenant it belongs to — is the genuinely hard part. nestjs-context solves it with two methods: serialize() and deserialize().


Why ALS does not cross boundaries

AsyncLocalStorage propagates a store along the async execution tree within a single process. The moment you cross out of that tree — you push a job onto a Redis-backed queue, you dispatch a remote durable task, you hand work to a sub-process — the ALS store is gone. The worker on the other side starts with an empty context. There is no magic that carries it; the store lives in process memory, and that memory does not travel.

So to carry the context across a boundary, you have to explicitly extract a serializable snapshot on one side and re-hydrate it on the other. That is exactly what serialize / deserialize are for.


serialize() — a flat carrier

Context.serialize() takes the active store and produces a ContextCarrier: a plain, JSON-safe object carrying only what is needed to re-hydrate on the other side.

interface ContextCarrier {
  traceId: string;
  tenantId?: string;
  userRef?: UserRef;
}

const carrier = Context.serialize();
// { traceId: '4bf9…4736', tenantId: 't1', userRef: { type: 'user', id: 42 } }

Note what is not there: no hydrated user entity, no database connection, no lazy ORM relations. Just the refs. This is precisely why the store carries a userRef rather than a full user (see The Store) — a { type, id } pair crosses a boundary trivially; an entity does not.

Context.serialize() returns undefined when called outside any context. Which fields it includes is configurable via the carrier option or a full serialize override — see Customization → cross-process carrier.


deserialize() — re-enter the store

On the other side of the boundary, Context.deserialize(carrier, fn) rebuilds a store from the carrier and runs fn inside it. It is the cross-process cousin of Context.run:

Context.deserialize(carrier, () => {
  // inside here, Context.traceId() / tenantId() / userRef() are restored
  return doTheWork();
});

Everything fn calls — synchronously or across awaits — sees the re-hydrated context, just as if it were running inside the original request.


The BullMQ pattern

The integration lives on the side that already owns the boundary — there is no nestjs-context-bullmq bridge package to install. You wire two lines: one where you enqueue, one where you consume.

On enqueue, attach the carrier to the job payload:

import { Context } from '@dudousxd/nestjs-context';

await queue.add('send-invoice', {
  ...payload,
  __ctx: Context.serialize(), // snapshot the current context onto the job
});

In the worker, re-hydrate before running the handler:

import { Context } from '@dudousxd/nestjs-context';

worker.process(async (job) => {
  return Context.deserialize(job.data.__ctx, () => handler(job));
});

Inside handler(job), Context.userRef() returns the principal who enqueued the job, Context.tenantId() returns their tenant, and Context.traceId() ties the worker's logs back to the originating request. Your handler code does not change at all — it reads the context exactly as it would inside an HTTP request.

The __ctx key is just a convention for "this is the serialized context" — name it whatever you like, as long as the enqueue and consume sides agree. If a job arrives without a carrier (an external producer, an older job), Context.deserialize still works: see the trace-id safety net below.


The durable integration

For durable workflows, the context rides the traceparent hook that already exists rather than a bespoke channel. You feed the engine a function that turns the current trace id into a W3C traceparent header:

import { Context, toTraceparent } from '@dudousxd/nestjs-context';

const engine = new WorkflowEngine({
  traceparent: () => toTraceparent(Context.traceId()!),
});

toTraceparent wraps the trace id into a 00-<traceId>-<spanId>-01 header. From there the durable RemoteTask carries the carrier across to the worker — including a Python worker — which re-hydrates the snapshot before running the step. The tenant and userRef ride along with the trace context, so a durable step knows not just which trace it belongs to but who and which tenant it is acting for.


Faithful traceparent propagation

The W3C traceparent header is more than its trace id — it carries a parent span-id and a trace-flags byte (whose low bit is the sampled decision). When a request arrives with a traceparent, the middleware parses all three parts, and nestjs-context re-emits them faithfully so the downstream traceparent genuinely continues the incoming trace instead of fabricating a new one.

Concretely:

  • The middleware seeds the trace id from the inbound traceparent and keeps the upstream span-id and trace-flags on the store (as ContextStore.traceparent). This is captured at request start.
  • toTraceparent(traceId, upstream?) takes an optional ParsedTraceparent. When you pass the captured upstream, the upstream span-id becomes the re-emitted parent-id and the upstream trace-flags are propagated verbatim — so an incoming …-00 (not sampled) round-trips as …-00 and is not silently flipped to sampled.
src/outbound.service.ts
import { Injectable } from '@nestjs/common';
import { Context, toTraceparent } from '@dudousxd/nestjs-context';

@Injectable()
export class OutboundService {
  /** A faithful downstream traceparent for an outgoing call. */
  private traceparentHeader(): string {
    const store = Context.get();
    // Pass the captured upstream so the sampled flag + parent span-id continue.
    return toTraceparent(store!.traceId, store?.traceparent);
  }

  async callDownstream(url: string): Promise<Response> {
    return fetch(url, { headers: { traceparent: this.traceparentHeader() } });
  }
}

With no upstream (a genuinely new trace — there was no inbound traceparent), toTraceparent(traceId) mints a random parent-id and defaults the flags to 01 (sampled) so downstream collectors keep the span.

ContextStore.traceparent is process-local on purpose — it is not part of the default cross-process carrier. It is the upstream's span context, useful only for re-emitting an HTTP traceparent from this process; shipping it across a queue/durable boundary would propagate a stale parent. The cross-boundary path carries traceId (plus tenantId / userRef), and the durable hook above re-wraps that trace id on the other side.

Use parseTraceparent(headers) if you want to capture an upstream span context yourself outside the middleware, and extractTraceparent(headers) when you only need the trace id.


W3C baggage interop

The bespoke ContextCarrier is ideal when you own both ends of the boundary. But sometimes you want the context to ride a standards-compliant header that any W3C-baggage-aware peer understands — an OTel SDK, an API gateway, a service in another language. For that, nestjs-context reads and writes the W3C baggage header.

Context.toBaggage() builds a baggage header value from the active context (or undefined when there is no context or no mappable field). By default it maps tenantId and userRef (the userRef as a compact type:id token):

src/outbound.service.ts
import { Injectable } from '@nestjs/common';
import { Context, toTraceparent } from '@dudousxd/nestjs-context';

@Injectable()
export class OutboundService {
  async callPeer(url: string): Promise<Response> {
    const headers: Record<string, string> = {
      traceparent: toTraceparent(Context.get()!.traceId, Context.get()?.traceparent),
    };
    const baggage = Context.toBaggage();
    if (baggage) {
      headers.baggage = baggage; // e.g. "tenantId=t1,userRef=user%3A42"
    }
    return fetch(url, { headers });
  }
}

On the receiving side, Context.fromBaggage(header, fn, opts?) is the symmetric counterpart of deserialize — it decodes the baggage header, builds a store, and runs fn inside it. Because baggage carries no trace id, the traceId invariant is satisfied from an optional traceparent (passed in opts) when supplied and valid, else a freshly generated id — mirroring how the middleware seeds:

src/peer.consumer.ts
import { Injectable } from '@nestjs/common';
import { Context } from '@dudousxd/nestjs-context';

@Injectable()
export class PeerConsumer {
  handle(headers: Record<string, string | undefined>, work: () => unknown) {
    return Context.fromBaggage(headers.baggage, work, {
      traceparent: headers.traceparent,
    });
  }
}

fromBaggage is tolerant: a malformed or absent baggage header simply yields a context with those fields unset rather than throwing. Malformed members (stray commas, missing =, percent-encoding that does not decode) are skipped.

Baggage is complementary to the bespoke carrier, not a replacement — choosing one does not change the other. Tune which fields ride baggage, namespace the keys (e.g. acme.tenant), or disable a field entirely via the baggage option on forRoot — see Customization → baggage keys.


The snapshot caveat

There is one behavior you must internalize before building long-running workflows on top of this.

The carrier is a snapshot, not a live view

A carrier captures the user and tenant at the moment you call serialize() — at dispatch time. A workflow that runs for days re-hydrates that snapshot, not the current live value.

If a user's role changes, or they are deactivated, or the tenant is reconfigured after the job was enqueued, the re-hydrated context still reflects the world as it was when the work was dispatched. The carrier is the history, not the present.

This is a deliberate decision, not a bug: a workflow step should generally act with the authority it was dispatched with, not with authority that may have changed underneath it. But if your use case genuinely needs the current value, re-resolve it inside the step from a stable id (e.g. look the user up fresh by userRef.id) rather than trusting the carrier's snapshot.


The trace-id safety net

ContextStore.traceId is a non-optional string (see the invariant), and deserialize protects it. A carrier that crosses the boundary without a trace id — for instance, one produced by a different runtime, or a job from an external system that never set one — would otherwise re-hydrate into a store with an undefined trace id, breaking the correlation that telescope and durable depend on.

To prevent that, deserialize (and the underlying carrier→store step) generates a fresh randomTraceId() when the incoming carrier has no trace id, emitting a one-time console.warn so the gap is visible without spamming your logs. The result is that any context you re-enter always has a valid trace id, no matter how messy the producer was.


Putting it together

The cross-process story is small on purpose — three concepts:

  1. serialize() flattens the active store to a JSON-safe carrier (refs only, no entities or connections).
  2. You ship the carrier however your boundary already ships data — a queue payload field, the durable traceparent hook.
  3. deserialize(carrier, fn) re-enters the store and runs your work inside it, with a trace-id safety net.

And one rule to remember: the carrier is a snapshot taken at dispatch time, not a live link to the present.


Next steps

  • Customization — choose which fields travel, or override serialize / deserialize entirely
  • The Store — why the store carries refs, and the trace-id invariant
  • Testing — build a context in tests for code that reads it after an await

On this page