# RUM Session Correlation

> Propagate RUM session IDs end-to-end across backend services and async workers so you can filter logs, traces, and spans by browser session.

Source: https://last9.io/docs/discover-applications-session-correlation/

RUM session correlation lets you take a session ID from the client and trace it all the way through your backend — across HTTP service calls, async queues, and workers. Once set up, you can filter logs, traces, and spans by session ID anywhere in your stack.

:::note
This page documents the setup for **web** clients. The same baggage mechanism is available on mobile via `L9BaggageConfig` — see the platform SDK reference: [Android](/docs/real-user-monitoring/android/), [iOS](/docs/real-user-monitoring/ios/), [React Native](/docs/real-user-monitoring/react-native/), [Flutter](/docs/real-user-monitoring/flutter/). The backend side of the flow is identical across platforms.
:::

:::note
For correlating native mobile RUM sessions with Browser RUM running inside an embedded WebView, see [WebView Session Correlation](/docs/discover-applications-webview-session-correlation/). That guide covers a different mechanism — native-injected JS globals, not baggage propagation.
:::

## How It Works

L9RUM sends a W3C `baggage` header alongside `traceparent` on every outgoing request. Backend services extract this baggage, attach it to spans and logs, and forward it to any downstream services — including async message queues.

```
Browser (L9RUM)
  ── traceparent + baggage: session.id=abc ──►  API Service
                                                  ├── span attribute: session.id=abc
                                                  ├── log field:      session.id=abc
                                                  └── SQS message attribute: baggage=session.id=abc
                                                            └──► Worker Service
                                                                    ├── span attribute: session.id=abc
                                                                    └── log field:      session.id=abc
```

## Prerequisites

- L9RUM SDK initialized with `network.backendCorrelation.enabled: true`
- Backend services instrumented with OpenTelemetry (Node.js guides: [Express](/docs/integrations/frameworks/javascript/expressjs/), [NestJS](/docs/integrations/frameworks/javascript/nestjs/), [Node.js](/docs/integrations/languages/nodejs/))

## Setup

1. ### Configure L9RUM

   Enable baggage propagation and add `session.id` to the allowed keys. Set the session ID value once the SDK initializes.

   ```javascript
   L9RUM.init({
     baseUrl: "YOUR_BASE_URL",
     headers: { clientToken: "YOUR_CLIENT_TOKEN" },
     resourceAttributes: {
       serviceName: "your-frontend-app",
       deploymentEnvironment: "production",
     },
     network: {
       backendCorrelation: {
         enabled: true,
         injectToAllRequests: true,
         baggage: {
           enabled: true,
           allowedKeys: ["session.id"],
         },
       },
     },
   });

   // Set the session ID — use any stable identifier for this browser session
   L9RUM.spanAttributes({
     "session.id": getYourSessionId(),
   });
   ```

   L9RUM will include `baggage: session.id=<value>` on every `fetch` and `XHR` request from that point on.

   :::note
   Call `L9RUM.spanAttributes()` again on route changes if the session ID can change across navigations.
   :::

2. ### Configure Backend Services

   Each backend service that receives requests from the browser (or from another service that forwarded the baggage) needs three additions to its OTel setup:

   1. `W3CBaggagePropagator` — parses the `baggage` header on incoming requests and forwards it on outgoing calls
   2. `BaggageSpanProcessor` — promotes baggage entries to span attributes so they appear in traces
   3. `logHook` on `WinstonInstrumentation` — injects baggage entries into every Winston log record automatically, alongside `trace_id` and `span_id`

   Update `instrumentation.ts` / `instrumentation.js` in each service:

**TypeScript**

```typescript
import {
  CompositePropagator,
  W3CTraceContextPropagator,
  W3CBaggagePropagator,
} from "@opentelemetry/core";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import {
  BatchSpanProcessor,
  SpanProcessor,
  Span,
} from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { context, propagation } from "@opentelemetry/api";
import type { Context } from "@opentelemetry/api";

// Promotes all baggage entries to span attributes
class BaggageSpanProcessor implements SpanProcessor {
  onStart(span: Span, parentContext: Context): void {
    const baggage = propagation.getBaggage(parentContext ?? context.active());
    if (!baggage) return;
    for (const [key, entry] of baggage.getAllEntries()) {
      span.setAttribute(key, entry.value);
    }
  }
  onEnd(): void {}
  forceFlush(): Promise<void> {
    return Promise.resolve();
  }
  shutdown(): Promise<void> {
    return Promise.resolve();
  }
}

const provider = new NodeTracerProvider();

provider.addSpanProcessor(new BaggageSpanProcessor());
provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter()));

provider.register({
  propagator: new CompositePropagator({
    propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()],
  }),
});

registerInstrumentations({
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false },
      // Runs after trace_id/span_id are injected — adds baggage to every log record
      "@opentelemetry/instrumentation-winston": {
        logHook: (_span, record) => {
          const baggage = propagation.getBaggage(context.active());
          if (!baggage) return;
          for (const [key, entry] of baggage.getAllEntries()) {
            record[key] = entry.value;
          }
        },
      },
    }),
  ],
});
```

**JavaScript**

```javascript
const {
  CompositePropagator,
  W3CTraceContextPropagator,
  W3CBaggagePropagator,
} = require("@opentelemetry/core");
const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node");
const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace-base");
const {
  OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-http");
const { registerInstrumentations } = require("@opentelemetry/instrumentation");
const {
  getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node");
const { context, propagation } = require("@opentelemetry/api");

// Promotes all baggage entries to span attributes
class BaggageSpanProcessor {
  onStart(span, parentContext) {
    const baggage = propagation.getBaggage(parentContext ?? context.active());
    if (!baggage) return;
    for (const [key, entry] of baggage.getAllEntries()) {
      span.setAttribute(key, entry.value);
    }
  }
  onEnd() {}
  forceFlush() {
    return Promise.resolve();
  }
  shutdown() {
    return Promise.resolve();
  }
}

const provider = new NodeTracerProvider();

provider.addSpanProcessor(new BaggageSpanProcessor());
provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter()));

provider.register({
  propagator: new CompositePropagator({
    propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()],
  }),
});

registerInstrumentations({
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false },
      // Runs after trace_id/span_id are injected — adds baggage to every log record
      "@opentelemetry/instrumentation-winston": {
        logHook: (_span, record) => {
          const baggage = propagation.getBaggage(context.active());
          if (!baggage) return;
          for (const [key, entry] of baggage.getAllEntries()) {
            record[key] = entry.value;
          }
        },
      },
    }),
  ],
});
```

Once registered, OTel handles propagation automatically:

- **Incoming requests**: the `baggage` header is parsed and stored in the active context
- **Outgoing HTTP calls**: the `baggage` header is forwarded to downstream services
- **Every Winston log line**: baggage entries (e.g. `session.id`) are injected alongside `trace_id` and `span_id` via `logHook` — no changes to your logger or middleware needed

:::note
`logHook` requires `@opentelemetry/instrumentation-winston` which is included in `getNodeAutoInstrumentations`. If you use Pino or another logger, inject baggage manually in your request middleware instead.
:::

3. ### Propagate Through SQS

   When a backend service publishes to SQS, it must inject the current context (including baggage) into the message attributes. The consumer extracts it before processing.

   :::note
   `@opentelemetry/instrumentation-aws-sdk` automatically handles `traceparent` and `tracestate` in SQS message attributes. `baggage` requires explicit inject/extract as shown below.
   :::

   SQS allows up to **10 MessageAttributes** per message. `traceparent`, `tracestate`, and `baggage` count as 3 toward this limit.

   **Producer — inject on send**

   ```typescript
   import { propagation, context } from "@opentelemetry/api";
   import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";

   const sqs = new SQSClient({});

   async function sendMessage(queueUrl: string, body: object) {
     const carrier: Record<string, string> = {};
     propagation.inject(context.active(), carrier); // injects traceparent, tracestate, baggage

     const messageAttributes: Record<
       string,
       { DataType: string; StringValue: string }
     > = {};
     for (const [key, value] of Object.entries(carrier)) {
       messageAttributes[key] = { DataType: "String", StringValue: value };
     }

     await sqs.send(
       new SendMessageCommand({
         QueueUrl: queueUrl,
         MessageBody: JSON.stringify(body),
         MessageAttributes: messageAttributes,
       }),
     );
   }
   ```

   **Consumer — extract on receive**

   ```typescript
   import { propagation, context, trace, SpanKind } from "@opentelemetry/api";

   const tracer = trace.getTracer("worker");

   async function processMessage(message: {
     MessageAttributes?: Record<string, any>;
   }) {
     const carrier: Record<string, string> = {};
     for (const [key, attr] of Object.entries(
       message.MessageAttributes ?? {},
     )) {
       // Handle both Lambda ESM format (stringValue) and SDK format (StringValue)
       const value = attr.stringValue ?? attr.StringValue;
       if (value) carrier[key] = value;
     }

     const parentCtx = propagation.extract(context.active(), carrier);

     await context.with(parentCtx, async () => {
       const baggage = propagation.getBaggage(context.active());
       const sessionId = baggage?.getEntry("session.id")?.value;

       await tracer.startActiveSpan(
         "process_message",
         { kind: SpanKind.CONSUMER },
         async (span) => {
           if (sessionId) span.setAttribute("session.id", sessionId);
           // ... processing logic
           span.end();
         },
       );
     });
   }
   ```

   :::caution
   When Lambda receives SQS messages via Event Source Mapping (ESM), message attribute keys use lowercase (`stringValue`, `dataType`) instead of the SDK's PascalCase (`StringValue`, `DataType`). The extraction code above handles both.
   :::

   **SNS → SQS**

   When messages flow through SNS before reaching SQS, inject baggage on the SNS publish call the same way as the SQS producer above. SNS forwards `MessageAttributes` to subscribed SQS queues unchanged, so the consumer extraction code works without modification.

## Verification

1. Open the browser, perform an action that triggers a backend request

2. In Last9, open a trace for that request — the root span should have a `session.id` attribute

3. Find a downstream span (auth service, internal API) — it should also carry `session.id`

4. If using SQS, find a worker span — `session.id` should appear there too

5. Filter logs by `session.id` to see all log lines across services for a single browser session

---

## Troubleshooting

- Services without `W3CBaggagePropagator` registered will silently drop the `baggage` header. Every service in the call chain needs it.
- Background jobs and queue consumers that originate independently (no browser session upstream) will have no `session.id`. Always handle the `undefined` case in your logging middleware.
- Keep baggage lean. Each key in `allowedKeys` is sent on every outgoing browser request. The W3C spec recommends staying well under 8 KB total.
- Never add auth tokens, cookies, API keys, or server-side ingestion credentials to baggage. Propagate only low-risk correlation identifiers that your backend is prepared to receive.

Please get in touch with us on [Discord](https://discord.com/invite/Q3p2EEucx9) or [Email](mailto:support@last9.io) if you have any questions.
