Skip to main content
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.
This guide assumes the Subtext capture snippet 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.

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.

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. Wrap the code that should carry the session id forward — context.with() only threads the baggage into the callback you pass it.
Call it once capture has actually started, not at module top level:
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.
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.
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.
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.
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.
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.

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:
(Attribute-equality and TraceQL syntax vary by Tempo version — confirm against your instance.)
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:
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:
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:
A Slack alert posts: “Error rate on POST /checkout up 4x in the last 15 minutes.”In Grafana → Explore → Tempo, run:
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:
Open a handful of the matching traces, copy subtext.session.url off each, and review them together:
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.
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:
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:
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.

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, 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).
1

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.
2

Discover error or slow traces

Run tempo_traceql-search with the same queries a human would paste into Explore:
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:
3

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.
4

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.
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.
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.