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

- Author: Kaushik Varanasi (Founder & CEO, Rocketgraph)
- Published: September 25, 2026
- Updated: September 25, 2026
- Canonical: https://blog.rocketlog.io/blog/frontend-observability-session-cohorts-opentelemetry
- Publisher: Rocketgraph (https://blog.rocketlog.io) — Rocketgraph is an observability platform that delivers metrics, logs, and traces at scale on object-storage economics, with AI agents that triage issues automatically.

**TL;DR**

Add `@opentelemetry/web-common` for session ids, write a ~15-line span processor that stamps your own business attributes, and register a `CompositePropagator` that puts `session.id` into W3C baggage alongside `traceparent`. On the backend, a four-line `BaggageSpanProcessor` with a key allow-list copies it onto server spans. Now you can group sessions by any attribute your app knows and rank the slices by how much worse they are than the population — and find the tenant failing 45% of checkouts while your global graph reads 7.8%.

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.

| Merchant | Sessions | Failed | Failure rate |
| --- | --- | --- | --- |
| m_northwind | 80 | 36 | **45.0%** |
| m_soylent | 115 | 10 | 8.7% |
| m_tyrell | 162 | 8 | 4.9% |
| m_lumon | 224 | 10 | 4.5% |
| m_acme | 297 | 8 | 2.7% |
| m_initech | 73 | 2 | 2.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 pts** — the 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`:

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

> 
One trap, because it cost us an afternoon. The package is experimental, and the README on `main` documents classes (`LocalStorageSessionStore`) that are not in the published release, which exports factory functions instead. Pin `0.222.0`, follow the type definitions, ignore the README. Its `SessionProvider` interface is `getSessionId(): string | null` — there is no `getSession()`.

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

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

```js
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:

```js
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:

```python
# 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))
```

> 
**Do not pass `ALLOW_ALL_BAGGAGE_KEYS`.** Baggage is a header the client controls. Copy all of it onto spans and any visitor can write arbitrary attributes into your traces — unbounded cardinality, and PII you never chose to collect. Allow the keys you named.

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.

```python
@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:

| Service | Spans | With `session.id` |
| --- | --- | --- |
| browser | 469 | 469 |
| api | 1784 | 720 |
| acquirer | 364 | 360 |

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.

---

About Rocketgraph: Rocketgraph is an observability platform that delivers metrics, logs, and traces at scale on object-storage economics, with AI agents that triage issues automatically.
More articles in markdown: https://blog.rocketlog.io/llms.txt
