# Browser

> Instrument a React, Vue, or plain JavaScript frontend with stock OpenTelemetry. Session-scoped traces that join your backend spans, and any attribute you attach becomes a cohort you can rank.

Your frontend emits OpenTelemetry spans like any other service. They join the same traces as your backend, land in the same store, and carry whatever business attributes your app knows — which tenant, which plan, which build, which experiment arm.

This is not RUM. There is no session replay and no separate frontend product. It is the same telemetry you already collect, extended to the one place your server cannot see.

> **Note:** Everything on this page is published OpenTelemetry. There is no Rocketgraph browser SDK, and there will not be one — your instrumentation stays portable to any OTLP backend.

## Install

```bash
npm install \
  @opentelemetry/sdk-trace-web \
  @opentelemetry/auto-instrumentations-web \
  @opentelemetry/instrumentation \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/context-zone \
  @opentelemetry/resources \
  @opentelemetry/core \
  @opentelemetry/api \
  @opentelemetry/web-common@0.222.0
```

> **Warning:** Pin `@opentelemetry/web-common`. It is experimental, and the README on `main` documents classes that are not in the published release — `0.222.0` exports factory functions instead. Follow the type definitions, not the README.

## Sessions

A session id turns a page load, four fetches, and the backend work they caused into *one person trying to do one thing*. Do not write your own — OpenTelemetry publishes generation, persistence, idle rotation, and span stamping.

```js
import {
  createSessionManager,
  createSessionSpanProcessor,
  createDefaultSessionIdGenerator,
  createLocalStorageSessionStore,
} from '@opentelemetry/web-common'

const sessionManager = createSessionManager({
  sessionIdGenerator: createDefaultSessionIdGenerator(),
  sessionStore: createLocalStorageSessionStore(),
  maxDuration: 7200,       // 2h hard cap, seconds
  inactivityTimeout: 1800, // 30m idle rotation, seconds
})
```

`createSessionSpanProcessor(sessionManager)` writes `session.id` onto every span. Its interface is `getSessionId(): string | null`.

## Your own attributes

This is the only code you write, and it is the only part specific to your business.

```js
/** Stamps app context on every span as it starts. */
class AppAttributes {
  onStart(span) {
    const ctx = window.__APP__ || {}                    // whatever your app sets
    if (ctx.tenantId)   span.setAttribute('tenant.id', ctx.tenantId)
    if (ctx.plan)       span.setAttribute('tenant.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() }
}
```

Use a span processor, not resource attributes. A resource is frozen when the provider is constructed — at page boot, before you know which tenant this is. `onStart` runs per span and sees what the app knows at that moment.

Name keys in dotted namespaces (`tenant.plan`, `checkout.version`). Cohorts discovers dimensions by sampling attribute keys and filtering on cardinality, so a key that reads like a dimension becomes one, and a key that behaves like an identifier is filtered out.

## Carry the session to your backend

`traceparent` links spans into one trace and carries **no attributes at all**. Your backend spans know they share a trace and nothing else. To get `session.id` onto them, use the other W3C 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() }
}
```

> **Warning:** Baggage is a header the client controls. Treat it as a correlation hint, never an identity claim. Anything that decides tenancy — account, plan, entitlement — must be re-derived server side from the request body or session cookie before you stamp it on a span.

## Wire it up

```js
import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web'
import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web'
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { ZoneContextManager } from '@opentelemetry/context-zone'
import { resourceFromAttributes } from '@opentelemetry/resources'

const provider = new WebTracerProvider({
  resource: resourceFromAttributes({
    'service.name': 'my-web-app',
    'service.version': '1.4.0',
    'deployment.environment': 'prod',
  }),
  spanProcessors: [
    createSessionSpanProcessor(sessionManager),  // session.id
    new AppAttributes(),                         // your attributes
    new BatchSpanProcessor(
      new OTLPTraceExporter({ url: `${window.location.origin}/v1/traces` }),
      { scheduledDelayMillis: 2000, maxExportBatchSize: 64 },
    ),
  ],
})

provider.register({
  contextManager: new ZoneContextManager(),
  propagator: new CompositePropagator({
    propagators: [
      new W3CTraceContextPropagator(),              // traceparent
      new SessionBaggagePropagator(sessionManager), // baggage
    ],
  }),
})
```

Processor order is execution order: session id, then your attributes, then the exporting `BatchSpanProcessor` last.

> **Warning:** The OTLP exporter parses its `url` with `new URL()` and rejects a relative path. Writing `'/v1/traces'` throws at module scope, which kills the bundle and renders a blank page. Build it from `window.location.origin`.

## Auto-instrumentation

```js
registerInstrumentations({
  instrumentations: [getWebAutoInstrumentations({
    '@opentelemetry/instrumentation-fetch': {
      propagateTraceHeaderCorsUrls: [/.*/],
      clearTimingResources: true,
    },
    '@opentelemetry/instrumentation-xml-http-request': {
      propagateTraceHeaderCorsUrls: [/.*/],
    },
  })],
})
```

> **Warning:** Without `propagateTraceHeaderCorsUrls`, OpenTelemetry deliberately omits `traceparent` on cross-origin requests. Your browser trace silently never joins your backend trace, and nothing logs a complaint.

### Real user clicks

`instrumentation-user-interaction` opens a span for each real click and runs your handler inside that span's context — so the `fetch` it fires becomes a **child** of the click, not a sibling. One tree per user action:

```
click  BUTTON[data-testid="pay"]     2.2ms   my-web-app
└─ HTTP POST /api/charge             118ms   my-web-app
   └─ POST /api/charge               113ms   api
      └─ POST /authorize           104.3ms   acquirer
```

```js
'@opentelemetry/instrumentation-user-interaction': {
  enabled: true,
  eventNames: ['click', 'submit'],
},
```

Restrict `eventNames`. The default set includes `mousedown` and `mouseup`, which triples every interaction for no extra information.

> **Note:** Span names come from the DOM target. On an app with generated class names they are unreadable, so add a `data-*` convention on the elements you care about. Client-side route changes are not captured — emit a span on pathname change if you need them.

## Send spans to your own origin

Point the browser at your own server and forward to Rocketgraph from there.

```python
@app.post("/v1/traces")
async def ingest_traces(request: Request):
    body = await request.body()
    req = urllib.request.Request(
        "https://ingress.rocketgraph.app/v1/traces", data=body, method="POST",
        headers={"Content-Type": request.headers.get("content-type"),
                 "Authorization": f"Bearer {ROCKETGRAPH_API_KEY}"},
    )
    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.** Same origin means no preflight to misconfigure, and no failure mode where spans are dropped with nothing logged anywhere.
2. **It is where a browser ingest key belongs.** A key shipped to browsers is public by definition and needs origin checks, rate limits, and quotas.
3. **It is where PII scrubbing goes.** URLs and attributes leak order ids and email addresses. Stripping them before storage is far cheaper than deleting them afterwards.

> **Warning:** Forward with `urllib`, not `requests` — `requests` is auto-instrumented, so forwarding a span batch emits a span, which is forwarded, which emits a span. Exclude the path from your own server instrumentation for the same reason: `OTEL_PYTHON_FASTAPI_EXCLUDED_URLS=v1/traces`.

## Receive the session on your backend

Baggage already flows — both the Python and Node SDKs ship `tracecontext,baggage` as default propagators, so it is extracted inbound and re-injected on outbound calls. What is missing is anything writing it onto a span.

```python Python
# pip install opentelemetry-processor-baggage
from opentelemetry import trace
from opentelemetry.processor.baggage import BaggageSpanProcessor

ALLOWED = {"session.id"}
provider = trace.get_tracer_provider()
if hasattr(provider, "add_span_processor"):
    provider.add_span_processor(BaggageSpanProcessor(lambda k: k in ALLOWED))
```

```javascript Node.js
const { propagation } = require('@opentelemetry/api')

class SessionFromBaggage {
  onStart(span, ctx) {
    const sid = propagation.getBaggage(ctx)?.getEntry('session.id')?.value
    if (sid) span.setAttribute('session.id', sid)
  }
  onEnd() {}
  forceFlush() { return Promise.resolve() }
  shutdown()   { return Promise.resolve() }
}
```

> **Warning:** Allow-list the keys. `ALLOW_ALL_BAGGAGE_KEYS` lets any visitor write arbitrary attributes onto your server spans — unbounded cardinality, and PII you never chose to collect.

## Verify

```sql
SELECT ServiceName, count() AS spans,
       countIf(SpanAttributes['session.id'] != '') AS with_session
FROM otel.otel_traces
WHERE Timestamp > now() - INTERVAL 10 MINUTE
GROUP BY ServiceName ORDER BY spans DESC
```

If the browser rows are populated and the backend rows are zero, the propagator was constructed but never passed to `provider.register()`. That is the failure every time.

Page navigations carry no baggage — `fetch` and XHR do, but document loads and static assets do not — so expect partial coverage on your web tier and full coverage on everything behind it. Session drill-down scopes by trace id rather than the attribute for exactly this reason, which also means it works for a frontend that propagates nothing at all.

## Next

  **Cohorts** — Rank any attribute by failure rate, then drill into the sessions and spans behind it.
  
  **OpenTelemetry web SDK** — Upstream documentation for everything on this page.
