> ## Documentation Index
> Fetch the complete documentation index at: https://subtext.fullstory.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# OpenTelemetry and Subtext: From a Trace to the Session

> Link the Subtext session to your OpenTelemetry traces, then jump from any red trace — a latency alert, an error spike, an incident dashboard — straight into the user's real session.

OpenTelemetry is the open, vendor-neutral standard for tracing your backend: spans and attributes flow through it to whatever OTLP-compatible observability backend you run — Grafana Cloud / Tempo, Honeycomb, or anything else. A trace tells you *that* a span crossed a latency threshold or exited with an error. It can't tell you *whether* a real person was actually blocked, what they saw, or the exact sequence behind it. Subtext can. Link the Subtext session to your traces — the session id rides along in Baggage, the clickable session URL sits on the root span — and every red trace becomes a jumping-off point: your agent reads `subtext.session.url`, opens that person's real session, and walks the decisive moments with screenshots, the component tree, network, and console. OpenTelemetry detects; Subtext diagnoses.

<Note>
  This guide assumes the [Subtext capture snippet](/docs/install/overview) is installed and capturing sessions, the OpenTelemetry JS web SDK is running in your browser app (`@opentelemetry/sdk-trace-web` plus fetch/XHR instrumentation), and you have an OTLP-compatible tracing backend. The examples use Grafana Cloud / Tempo, but any OTLP backend works.
</Note>

## Attach the session URL

Session replay is your front-end telemetry; OpenTelemetry is your back-end telemetry, and wiring the seam links them both directions. Put the Subtext session id in Baggage so it propagates to the backend, stamp the clickable session URL on the root span, and — so the link runs the other way too — stamp the active trace id back onto the session. Elsewhere in these guides the key is `subtext_url`; on OpenTelemetry spans it follows OTel attribute-naming conventions as `subtext.session.url` / `subtext.session.id`. Keep the browser footprint minimal; you are not replacing session replay, only linking to the trace.

### Set up browser tracing

Register a `WebTracerProvider` with the W3C trace-context and Baggage propagators, and scope trace-header injection to your own API origins.

```js theme={null}
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { ZoneContextManager } from '@opentelemetry/context-zone'
import {
  CompositePropagator,
  W3CTraceContextPropagator,
  W3CBaggagePropagator,
} from '@opentelemetry/core'
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'

const provider = new WebTracerProvider({
  spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: '/v1/traces' }))],
})

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

registerInstrumentations({
  instrumentations: [
    new FetchInstrumentation({
      // Only inject traceparent/baggage to your own API origins (never third-party).
      propagateTraceHeaderCorsUrls: [/^https:\/\/api\.yourapp\.com/],
    }),
  ],
})
```

### Put the session id in Baggage

Once capture has started, put `subtext.session.id` in Baggage so it propagates to the backend. The `format: 'id'` option returns the bare session id — see [Fullstory's Get Session Details docs](https://developer.fullstory.com/browser/get-session-details/). Wrap the code that should carry the session id forward — `context.with()` only threads the baggage into the callback you pass it.

```js theme={null}
import { propagation, context } from '@opentelemetry/api'

// Wrap this around the code that should carry the session id forward — e.g.
// your app's render/bootstrap call, or a request handler. context.with()
// only threads the baggage into the callback you pass it (and, with
// ZoneContextManager, into async work scheduled from inside that callback —
// fetches, timers, promise chains). Code that runs after context.with()
// returns, outside the callback, never sees it.
export function attachSubtextSession(next) {
  const sessionId = FS('getSession', { format: 'id' })
  if (!sessionId) return next()

  const baggage = (propagation.getActiveBaggage() ?? propagation.createBaggage()).setEntry(
    'subtext.session.id',
    { value: String(sessionId) } // W3C Baggage values must be strings
  )
  return context.with(propagation.setBaggage(context.active(), baggage), next)
}
```

Call it once capture has actually started, not at module top level:

```js theme={null}
FS('observe', {
  type: 'start',
  callback: () => attachSubtextSession(() => renderApp()),
})
```

This wrap covers the initial load and async work rooted in it. For robustness, also re-establish baggage at your request boundary (wrap your fetch/data layer, or set it in a `FetchInstrumentation` request hook) so every outbound request carries `subtext.session.id`, not just those rooted in the initial render.

### Set the session URL on the root span

Stamp the clickable `subtext.session.url` on the root span with a `SpanProcessor`, which keeps it out of the propagated header. Never put the URL in Baggage.

```js theme={null}
import { trace } from '@opentelemetry/api'

class SubtextRootAttributeProcessor {
  onStart(span, parentContext) {
    // Root span only: no parent span in the context the SDK resolved for this span.
    if (trace.getSpan(parentContext)) return
    const url = FS('getSession', { format: 'url.now' })
    if (url) span.setAttribute('subtext.session.url', url)
  }
  onEnd() {}
  shutdown() {
    return Promise.resolve()
  }
  forceFlush() {
    return Promise.resolve()
  }
}
// Add alongside BatchSpanProcessor in WebTracerProvider({ spanProcessors: [...] }).
```

After wiring, confirm in a live request that the outbound call carries both a `traceparent` and a `baggage: subtext.session.id=...` header.

### Promote Baggage onto backend spans

So every server span carries the session id, add the Baggage span processor (OTel contrib) in each backend service.

```bash theme={null}
npm i @opentelemetry/baggage-span-processor
```

```js theme={null}
import { BaggageSpanProcessor } from '@opentelemetry/baggage-span-processor'

// Copy only the Subtext session id onto spans. ALLOW_ALL_BAGGAGE_KEYS (also
// exported by this package) copies every baggage entry — skip it here so a
// key some other tool later adds to baggage doesn't silently land on every
// span attribute too.
const subtextBaggageProcessor = new BaggageSpanProcessor((key) => key === 'subtext.session.id')
// Add subtextBaggageProcessor alongside your other span processors on the
// NodeTracerProvider / NodeSDK spanProcessors array.
```

`BaggageSpanProcessor` only has baggage to read if the Node tracer extracts the incoming `baggage` header. The OpenTelemetry JS NodeSDK does this by default (`OTEL_PROPAGATORS` defaults to `tracecontext,baggage`); if you've narrowed the propagators, add the W3C Baggage propagator back or `subtext.session.id` never enters server context.

### Stamp the trace id back onto the session

Stamp the active trace id onto the Fullstory session so a session action deep-links to the backend trace. This is your app's instrumentation, not automatic capture-snippet behavior — call it wherever your code can see the trace you want linked. It stamps one `otel_trace` event per call.

```js theme={null}
import { context, trace } from '@opentelemetry/api'

const span = trace.getSpan(context.active())
const ctx = span?.spanContext()
if (ctx) {
  FS('trackEvent', {
    name: 'otel_trace',
    properties: { trace_id: ctx.traceId, span_id: ctx.spanId },
  })
}
```

<Note>
  `FS('getSession', { format: 'id' })`, `FS('observe')`, and `FS('trackEvent')` are Fullstory Browser API operations not covered in Subtext's API pages — see the Fullstory developer docs and verify them against your installed snippet version. Likewise verify the `@opentelemetry/*` import paths and the `@opentelemetry/baggage-span-processor` exports (`BaggageSpanProcessor`, `ALLOW_ALL_BAGGAGE_KEYS`) against your installed versions; the JS and contrib API surfaces shift between releases.
</Note>

<Warning>
  Don't read `FS('getSession')` at module top level — it returns `null` until the session has started; call it from `FS('observe', { type: 'start', callback })`. Don't put `subtext.session.url` in Baggage — it is long and grants replay access, and Baggage rides every hop into logs and headers, so propagate only `subtext.session.id`. Scope `propagateTraceHeaderCorsUrls` to your own API origins (never third-party), and accept the `traceparent` and `baggage` headers in your API's CORS config or the browser drops them. Keep user PII out of span attributes and baggage. The session id and URL change on a new session and when `FS('setIdentity')` is called with a different `uid`, so re-wrap the following work in another `attachSubtextSession(next)` on re-identify — the value is frozen at set-time and won't refresh on a bare re-read.
</Warning>

<Info>
  Interesting traces (errors, high latency, session-flagged) must survive sampling. Use tail-based sampling at the OpenTelemetry Collector — keep 100% of error / high-latency traces and sample the rest — rather than dropping traces head-side in the browser.
</Info>

## From signal to session

However you first cross the seam — an alert, a dashboard panel, or a session someone already has open — the path is the same: get to the `subtext.session.url`, then hand it to your agent for a Detect-vs-Diagnose read. The trace tells you a span crossed a latency threshold or errored; the session tells you whether a real person was actually blocked, what they saw, and the precise sequence behind it. Because `subtext.session.id` rides in Baggage and lands on every backend span for a request, you can also search Tempo by session id for every trace generated during one session:

```
{ span.subtext.session.id = "<id>" }
```

(Attribute-equality and TraceQL syntax vary by Tempo version — confirm against your instance.)

<AccordionGroup>
  <Accordion title="A Grafana latency alert at 2am (trace → session)">
    A Grafana alert pages you (PagerDuty, Slack, whatever's wired up): *"P99 latency on checkout-service > 2s for 5m."*

    Open the alert, jump to Explore → Tempo, and run a TraceQL query scoped to the alerting window for slow, session-tagged traces:

    ```
    { duration > 2s && span.subtext.session.url != "" }
    ```

    That single-span form matches when the *root* span is the slow one. If the slowness is in a child span, split it into two spansets so TraceQL matches across spans in the same trace:

    ```
    { span.subtext.session.url != "" } && { duration > 2s }
    ```

    Open a matching trace, find the root span — that's the one carrying the attribute — and copy `subtext.session.url`. Then hand it to your agent:

    ```text theme={null}
    A Grafana alert paged me: P99 latency on checkout-service breached 2s. Here's a session from a
    trace that got caught in the same query: <PASTE subtext.session.url>

    It's 2am — I need signal fast. Open the session, give me a Detect-vs-Diagnose read. Is this a
    real user-facing slowdown or a backend blip nobody noticed? What did the user see, and is it
    isolated or systemic? One paragraph, plus the single most important screenshot.
    ```
  </Accordion>

  <Accordion title="A spike in checkout errors in Slack (trace → session)">
    A Slack alert posts: *"Error rate on POST /checkout up 4x in the last 15 minutes."*

    In Grafana → Explore → Tempo, run:

    ```
    { status = error && span.subtext.session.url != "" }
    ```

    Since `subtext.session.url` lives on the root span only, this matches only traces where the *root* span itself errored. To also catch a session-tagged trace where the error surfaced on a child span, split the conditions into separate spansets:

    ```
    { span.subtext.session.url != "" } && { status = error }
    ```

    Open a handful of the matching traces, copy `subtext.session.url` off each, and review them together:

    ```text theme={null}
    Tempo is showing a 4x spike in errors on /checkout. Here are sessions from a sample of the
    affected traces: <PASTE 3-4 subtext.session.url values>

    Review them together. What's the common trigger — same downstream call, same input, same
    device? Which sessions show the user actually stuck vs. recovered? Give me one root-cause
    hypothesis and a go/no-go on whether this needs a hotfix tonight.
    ```
  </Accordion>

  <Accordion title="A support ticket that only makes sense from the session (session → trace)">
    A customer writes in: *"Checkout just hung on me, I had to refresh."* Support already has the Subtext session link for that visit — from the in-app support widget or a saved bookmark — but no trace id yet.

    Open the session in Subtext and step through it up to the point it hangs. Find the `otel_trace` custom event on the session timeline — your app's instrumentation stamps one onto the session for the trace it wants linked. Open its properties and copy `trace_id` (and `span_id` if you want the exact span). In Grafana → Explore → Tempo → TraceQL tab, paste the trace id directly into the query field to jump straight to that trace — an exact id lookup, no TraceQL query needed.

    ```text theme={null}
    A customer says checkout hung on them. Here's their session: <PASTE subtext.session.url>

    Open it, confirm what happened on screen, then pull the `otel_trace` event and tell me the
    trace_id. I want to hand that to backend on-call so they can pull up the exact trace in Tempo and
    see which downstream call actually stalled.
    ```
  </Accordion>

  <Accordion title="An incident postmortem, drilling from a dashboard (trace → session)">
    During or after an incident, a Grafana dashboard panel shows a clear anomaly window — a latency or error-rate spike correlated with a drop in conversions.

    From the dashboard panel, drill down to Explore → Tempo, scope the TraceQL query to the incident window, and filter for session-tagged traces inside the spike:

    ```
    { status = error && span.subtext.session.url != "" }
    ```

    For a pure latency spike, swap in the `duration > <threshold>` filter from the first scenario; use the split-spanset variant if the error or slow span surfaced on a child span. Open a trace inside the window, copy `subtext.session.url`, and hand it over:

    ```text theme={null}
    We had an incident between <start> and <end>. This Grafana dashboard shows <latency/error rate>
    spiking against a conversion drop. Here's a user session from inside that window:
    <PASTE subtext.session.url>

    Open it and tell me the human story behind the graph — what did this user try to do, where did
    it fail, and what did the failure look like on screen? I'm writing the postmortem; I need the
    customer-impact narrative, not just the metrics.
    ```
  </Accordion>
</AccordionGroup>

<Tip>
  For an incident, ask your agent to roll the sessions into one proof document (`doc-create`) with the key screenshots and a single shared link — then drop it into the postmortem or incident channel so the trace graph carries its human-impact caption. If the session proves the alert was noise (a span errored or was slow, but the user was unaffected), feed that back into your Collector's tail-sampling policy or your alert threshold.
</Tip>

## For agents

An autonomous agent can run the whole loop against your tracing backend directly: discover the error or high-latency traces, extract the stamped `subtext.session.url`, and hand each session to the Subtext MCP. Grafana ships a first-party MCP server two ways: the open-source [`grafana/mcp-grafana`](https://github.com/grafana/mcp-grafana), which you run locally (stdio via `uvx mcp-grafana`, a downloaded binary, or `go install`) or self-host (Streamable-HTTP/SSE via Docker or the Helm chart) authenticated with `GRAFANA_URL` + `GRAFANA_SERVICE_ACCOUNT_TOKEN`; and, for Grafana Cloud, a hosted MCP server at `https://mcp.grafana.com/mcp` (Streamable HTTP, OAuth 2.1, public preview).

<Steps>
  <Step title="Connect the Grafana MCP and confirm the trace tools">
    Trace search isn't built into `mcp-grafana` — it's proxied from Tempo's own MCP server, exposed as `tempo_`-prefixed tools only when your Tempo datasource has that server enabled (`query_frontend.mcp_server.enabled`) and `mcp-grafana` wasn't started with `--disable-proxied`. List the live server's tools first; if `tempo_traceql-search` isn't there, that's why — fall back to the same TraceQL a human pastes into Grafana Explore. Use `tempo_get-attribute-names` / `tempo_get-attribute-values` to confirm `subtext.session.url` is actually indexed before you query on it.
  </Step>

  <Step title="Discover error or slow traces">
    Run `tempo_traceql-search` with the same queries a human would paste into Explore:

    ```
    { duration > 2s && span.subtext.session.url != "" }         # slow + session-tagged
    { status = error && span.subtext.session.url != "" }        # errored + session-tagged
    ```

    Because `subtext.session.url` lives on the root span only, neither matches a trace where the error or slow span is a *child* span. Split into two spansets so TraceQL matches across spans in the same trace:

    ```
    { span.subtext.session.url != "" } && { status = error }     # errored child span
    { span.subtext.session.url != "" } && { duration > 2s }      # slow child span
    ```
  </Step>

  <Step title="Extract the session URL (root span only)">
    If the search response already carries the root span's attributes, read `subtext.session.url` off it directly. Otherwise — or if you matched via the split-spanset query — call `tempo_get-trace(trace_id)` for the full trace, find the root span (no parent span reference), and read `subtext.session.url` from its attributes. Verify the exact response shape (attribute key casing, nesting) against a live call.
  </Step>

  <Step title="Hand off to Subtext">
    For each URL, call `review-open(session_url=<subtext.session.url>)` on the Subtext MCP. The root-span URL is captured with `url.now`, so the review opens deep-linked to the moment the trace ran.
  </Step>
</Steps>

Starting from a session instead of a trace (a support ticket with a `subtext.session.url` and no trace id yet): `review-open` the session, walk the timeline to the `otel_trace` custom event, read `trace_id` (and `span_id`) off its properties, then `tempo_get-trace(trace_id)` for an exact id lookup — no TraceQL query needed.

<Note>
  The examples use Grafana Cloud / Tempo, but OpenTelemetry is vendor-neutral — spans and attributes flow to whatever OTLP backend you run. Honeycomb, for instance, ships its own hosted MCP server (plus a Query Data API); the pattern is identical: discover traces filtered on `subtext.session.url != ""` plus your latency/error condition, extract the attribute, then run the same `review-open` hand-off. `subtext.session.url` only round-trips if it was attached to the root span at capture time, and TraceQL syntax and the `mcp-grafana` / proxied `tempo_` tool names and params shift between releases — confirm both against your current server before wiring an automation on top of this recipe.
</Note>

## Related

* [Session Review overview](/docs/session-review/overview) — what your agent does once a session is open.
* [Install the capture snippet](/docs/install/overview) — required before any session URL or id exists to attach.
