Reading time · 9 minSeptember 25, 2026

Frontend Observability Without RUM: Session Cohorts with Stock OpenTelemetry

You can find the slice of users who are broken — one tenant, one plan, one build — using only published OpenTelemetry packages: @opentelemetry/web-common for session ids, a ~15-line span processor for your own attributes, and W3C baggage to carry the session into your backend spans. No RUM vendor, no proprietary agent, no schema to register.

Kaushik VaranasiKaushik Varanasi

Key takeaways

  • A platform-wide failure rate hides per-tenant disasters: in a real 30-minute window one merchant failed 45% of checkouts while the global rate read 7.8%, a gap nobody would page on.
  • OpenTelemetry already publishes browser session management in @opentelemetry/web-common — generation, localStorage persistence, idle rotation and span stamping — so do not write your own session id.
  • traceparent carries no attributes. It links spans into one trace and nothing more; to get session.id onto your backend spans you must also propagate W3C baggage.
  • The only custom code needed is a ~15-line span processor that stamps your business attributes, because a resource is frozen at provider construction and the tenant is not known until the page has loaded.
  • Allow-list the baggage keys you copy onto server spans. Baggage is a client-controlled header, so ALLOW_ALL_BAGGAGE_KEYS hands any visitor unbounded cardinality and uninvited PII in your traces.
  • Rank cohorts by the lower bound of a Wilson interval rather than the raw failure rate, so a cohort with one failure in three sessions sinks on its own without an arbitrary minimum-sample threshold.

Here is a real thirty-minute window from a payments app we run as a test bed. 951 checkout sessions, six merchants on a shared platform.

MerchantSessionsFailedFailure rate
m_northwind803645.0%
m_soylent115108.7%
m_tyrell16284.9%
m_lumon224104.5%
m_acme29782.7%
m_initech7322.7%

Northwind is on fire. Nearly half of their customers cannot pay.

Now look at the number your dashboard shows. Overall failure rate: 7.8%. Take Northwind out and it is 4.4%. One merchant is losing half their revenue, and the only trace of it on the platform graph is three and a half points.

3.4 ptsthe entire platform-level signal from one merchant failing 45% of its checkouts

Nobody is going to page on that. Nobody is going to notice it on a wall display. You will find out when Northwind's founder emails your CEO.

The uncomfortable part: every byte of data needed to see this was already in the traces. It just was not grouped by anything a human cares about.

The thing people want, and the thing they say they do not

We got asked for this by someone who opened with, roughly: we do not want RUM, we do not want session replay, we do not want another frontend vendor. We want to attach our own attributes in the browser and see which cohorts are failing.

That is a reasonable thing to want, and it is not RUM. RUM products are built around watching a user — replays, heatmaps, rage clicks, Core Web Vitals leaderboards. This is the opposite direction. You are not watching anyone. You are attaching business facts you already know — which tenant, which plan, which region, which build, which experiment arm — to spans you are already emitting, so you can ask which slice is worse and have the answer reach all the way down to the database call.

The browser here is not a special product with its own agent and its own storage. It is one more service emitting OpenTelemetry, joining the same trace, landing in the same table.

What the browser has to send

Two things beyond what auto-instrumentation gives you.

A session id, so a page load, four fetches and the backend work they caused group into "one person trying to do one thing" rather than nine unrelated spans.

Your attributes, because OpenTelemetry has no idea what a merchant is.

Neither requires a vendor SDK.

Sessions: do not write this yourself

People write their own session id. A UUID in localStorage, a rotation timer, a bit of code to handle the tab being open for three days. It is about twenty lines and everybody's twenty lines are subtly different.

OpenTelemetry publishes this already, in @opentelemetry/web-common:

import {
  createSessionManager, createSessionSpanProcessor,
  createDefaultSessionIdGenerator, createLocalStorageSessionStore,
} from '@opentelemetry/web-common'
 
const sessionManager = createSessionManager({
  sessionIdGenerator: createDefaultSessionIdGenerator(),
  sessionStore: createLocalStorageSessionStore(),
  maxDuration: 7200,       // 2h hard cap
  inactivityTimeout: 1800, // 30m idle rotation
})

createSessionSpanProcessor(sessionManager) then stamps session.id on every span the page produces. Generation, persistence, idle rotation, stamping — done, by the same people who wrote the spec.

Your attributes: a span processor, ~15 lines

This is the only code in this post that you have to write, and the only part actually specific to your business.

class AppAttributes {
  onStart(span) {
    const ctx = window.__APP__ || {}
    if (ctx.merchantId) span.setAttribute('merchant.id', ctx.merchantId)
    if (ctx.plan)       span.setAttribute('merchant.plan', ctx.plan)
    if (ctx.region)     span.setAttribute('deployment.region', ctx.region)
    if (ctx.experiment) span.setAttribute('experiment.arm', ctx.experiment)
  }
  onEnd() {}
  forceFlush() { return Promise.resolve() }
  shutdown()   { return Promise.resolve() }
}

A span processor rather than resource attributes, and the reason is not stylistic. A resource is frozen when the provider is constructed — at page boot, before you know which merchant this is, and certainly before the user goes back and picks a different one. onStart runs per span and sees what the app knows at that moment.

Note what is not here: no list of allowed keys, no schema, no registration call. Add checkout.variant tomorrow and it becomes a dimension you can group by, with no deploy on the backend.

Getting the session across the network

Here is the mistake almost everyone makes, including us for a while.

You instrument the browser, you instrument the API, you see a connected trace in the waterfall, and you conclude the session is propagating. It is not. traceparent carries a trace id and a parent span id. That is the entire payload. It links spans; it transports no attributes at all. Your backend spans know they are in the same trace as a browser span and nothing else.

So when you query "spans in session X", you get the browser's five spans and none of the backend's. We shipped a session drill-down that showed exactly one service and looked broken, for precisely this reason.

The W3C mechanism for carrying values across the boundary is the other header: baggage.

import { propagation } from '@opentelemetry/api'
import {
  CompositePropagator, W3CTraceContextPropagator, W3CBaggagePropagator,
} from '@opentelemetry/core'
 
class SessionBaggagePropagator {
  constructor(sessions) {
    this.sessions = sessions
    this.inner = new W3CBaggagePropagator()
  }
  inject(ctx, carrier, setter) {
    let bag = propagation.getBaggage(ctx) ?? propagation.createBaggage()
    const sid = this.sessions.getSessionId()
    if (sid) bag = bag.setEntry('session.id', { value: sid })
    this.inner.inject(propagation.setBaggage(ctx, bag), carrier, setter)
  }
  extract(ctx, carrier, getter) { return this.inner.extract(ctx, carrier, getter) }
  fields() { return this.inner.fields() }
}
 
provider.register({
  contextManager: new ZoneContextManager(),
  propagator: new CompositePropagator({
    propagators: [
      new W3CTraceContextPropagator(),              // joins the trace
      new SessionBaggagePropagator(sessionManager), // carries the session
    ],
  }),
})

While you are in register, the other line that silently decides whether any of this works:

getWebAutoInstrumentations({
  '@opentelemetry/instrumentation-fetch': {
    propagateTraceHeaderCorsUrls: [/.*/],
  },
})

Without it, OpenTelemetry deliberately omits traceparent on cross-origin requests — a sensible default that, left alone, means your browser trace never joins your backend trace and nothing anywhere logs a complaint.

The backend side is four lines

Both the Python and Node SDKs already ship tracecontext,baggage as their default propagators, so baggage is extracted on the way in and re-injected on every outbound call without you doing anything. Two hops from the browser, our fake acquiring bank still has the session id in context.

What is missing is anything that writes it onto a span. There is a stock processor for that too:

# pip install opentelemetry-processor-baggage
from opentelemetry import trace
from opentelemetry.processor.baggage import BaggageSpanProcessor
 
ALLOWED = {"session.id"}
trace.get_tracer_provider().add_span_processor(
    BaggageSpanProcessor(lambda key: key in ALLOWED))

The same principle, stated once so it sticks: baggage is a correlation hint, never an identity claim. In our app merchant.id travels in baggage too, and the server deliberately ignores it, re-deriving the merchant from the request body before stamping the span. A client-controlled header must never decide which tenant a span is attributed to.

One more decision: where the browser posts

Point the browser at your own origin and forward server side. Not at the collector.

@app.post("/v1/traces")
async def ingest_traces(request: Request):
    body = await request.body()
    req = urllib.request.Request(COLLECTOR, data=body, method="POST",
        headers={"Content-Type": request.headers.get("content-type")})
    with urllib.request.urlopen(req, timeout=5) as r:
        return Response(content=r.read(), status_code=r.status)

Three reasons, in the order they matter:

  1. No CORS. We spent a day on a collector returning 405 to preflight with a valid CORS block and ["*"] origins. Same-origin ingest deleted the entire question, along with the failure mode where spans vanish and nothing logs anything.
  2. It is where a browser ingest key belongs. A key shipped to browsers is public by definition. It needs origin checks, rate limits and per-tenant quotas — none of which a collector will give you.
  3. It is where PII scrubbing goes. URLs and attributes leak order ids and email addresses. Stripping them before storage is much cheaper than deleting them afterwards.

Two details that are not optional. Forward with urllib, not requests — requests is auto-instrumented, so forwarding a span batch emits a span, which gets forwarded, which emits a span. And exclude the path from your own server instrumentation (OTEL_PYTHON_FASTAPI_EXCLUDED_URLS=v1/traces) for the same loop from the other side.

What you can ask once it works

Group sessions by any attribute that showed up, then rank the values by how much worse they are than everyone else. Two details make that ranking survive contact with real data.

Count sessions, not spans. "What fraction of this merchant's spans errored" overweights whoever retried the most. The question a payments team asks is what fraction of their checkouts failed.

Sort by a Wilson lower bound, not the raw rate. A cohort with 1 failure in 3 sessions has a 33% failure rate and would sit at the top of a naive list forever. Its 95% lower bound is about 5%, so it sinks on its own — no arbitrary "minimum 50 sessions" threshold that someone then has to defend in review.

Then report lift next to it: share of all failures divided by share of all traffic.

5.8×Northwind is 8.4% of sessions and 48.6% of failures — that single number is the finding

Click the row and you get the sessions. Click a session and you get every span it touched — the browser's documentLoad, the HTTP POST from the checkout form, the FastAPI server span, the client span to the acquirer, and the acquirer's own span with payment.decline_code = issuer_rejected_bin on it. Seventeen spans across three services, from a merchant name.

A routing rule shipped for one merchant was sending their traffic down a BIN range the issuer rejects. Nobody had to know to look for it.

Two things that will bite you

Page loads carry no baggage. Baggage rides on fetch and XHR. The document navigation and the static assets have none, so those server spans have no session.id. Our coverage, measured:

ServiceSpansWith session.id
browser469469
api1784720
acquirer364360

The API gap is entirely GET / and GET /assets/*. So do not build session drill-down on the attribute — collect the trace ids belonging to the session and take every span in those traces. traceparent has no such gap. As a bonus, that version also works for a customer whose frontend propagates nothing at all.

Let the batch flush. If the tab closes before BatchSpanProcessor exports, you lose the tail of every session — which is disproportionately where the failures are. Your data will quietly understate how bad things are.

The bill

  • Four stock packages plus one pinned experimental one.
  • About fifteen lines you write, describing your own business.
  • Four lines on each backend service.
  • No vendor SDK in your bundle, no proprietary agent, no schema to maintain.

Everything above is plain OpenTelemetry. If you rip out the backend it points at tomorrow, the instrumentation keeps working against whatever you point it at next. That is the part worth insisting on — not because portability is a virtue in the abstract, but because the alternative is that finding your broken 8% is something you rent rather than something you own.

Frequently asked questions

Can I do frontend observability with OpenTelemetry instead of a RUM tool?

Yes, and it is a different thing rather than a cheaper version of the same thing. RUM products are built around watching a user — session replay, heatmaps, rage clicks. OpenTelemetry browser instrumentation emits spans from the browser that join the same distributed trace as your backend, carrying whatever business attributes you attach. That lets you ask which slice of users is failing and follow the answer down to the database call, which is a question replay tooling answers badly.

Does OpenTelemetry have a browser SDK?

Yes. @opentelemetry/sdk-trace-web is the provider, @opentelemetry/auto-instrumentations-web covers document load, fetch and XHR, and @opentelemetry/exporter-trace-otlp-http exports over OTLP/HTTP. Session handling lives in @opentelemetry/web-common, which is experimental and should be version-pinned.

How do I get a session ID onto my backend spans?

Through W3C baggage, not traceparent. traceparent carries only a trace id and a parent span id, so your backend spans join the trace but learn nothing about the user. Register a CompositePropagator combining W3CTraceContextPropagator with a propagator that injects session.id as a baggage entry, then on the server use a BaggageSpanProcessor with an explicit key allow-list to copy it onto spans.

Is it safe to trust attributes that arrive in the baggage header?

No. Baggage is a header the client controls, so treat it as a correlation hint and never as an identity claim. Record session.id from baggage, but re-derive anything that decides tenancy — merchant, account, plan — server side from the request body or session cookie before you stamp it on a span. Also allow-list which baggage keys can become span attributes, or any visitor can write arbitrary attributes into your traces.

Why does session drill-down scope by trace ID instead of the session attribute?

Because baggage rides on fetch and XHR only. The document navigation, static assets and anything a proxy terminates carry no baggage, so those server spans have no session.id — in our reference app 720 of 1784 API spans had it, and the entire gap was page loads and asset requests. Collecting the trace ids belonging to a session and taking every span in those traces closes the gap, and has the side benefit of working for a frontend that propagates nothing at all.

How many distinct attribute values can a cohort dimension have?

Tens of thousands per dimension is fine, because dimensions are discovered by sampling attribute keys rather than registered in advance. A key with a single value is not a dimension and a key with roughly as many distinct values as sessions is an identifier; both are filtered out automatically. Put unbounded identifiers like order ids or full URLs on spans if you want them for debugging, but do not expect to group by them.

Kaushik Varanasi

Kaushik Varanasi

Founder & CEO, Rocketgraph

Kaushik founded Rocketgraph to make observability affordable at any scale. He writes about telemetry economics, object-storage architectures, and using AI agents to triage production incidents.

Read next