Web SDK
autotel-web propagates W3C traceparent headers from the browser to your backend. The backend does all the real tracing; the browser only injects headers.
Installation
Section titled “Installation”npm install autotel-webLean Mode (Default)
Section titled “Lean Mode (Default)”~1.6KB gzipped. Only injects traceparent on fetch/XHR:
import { init } from 'autotel-web';
init({ service: 'my-frontend-app' });
// All fetch/XHR calls now include traceparent headerfetch('/api/users'); // ← traceparent automatically injected!Full Mode
Section titled “Full Mode”~40-50KB gzipped. Real browser spans, plus the signals a tracing backend cannot derive for itself:
import { initFull } from 'autotel-web/full';
initFull({ service: 'my-frontend-app', endpoint: 'https://collector.example.com', sampleRate: 0.5, captureNavigation: true, captureFetch: true, captureWebVitals: true, captureErrors: true,});endpoint is the collector base — /v1/traces and /v1/logs are appended per
signal. Pass '' for same-origin, which needs a /v1/traces proxy.
- Navigation
- Fetch/XHR, with timing
- Unhandled errors
- Network timing events
Events
Section titled “Events”Everything else is an event, which in the OpenTelemetry data model is a log
record — not a zero-duration span. A span named browser.web_vital is invisible
to every log and event dashboard and is noise in trace search, so these go to
the log pipeline instead:
| Event | What it says |
|---|---|
browser.web_vital |
One per metric — lcp, inp, cls, fcp, ttfb |
app.widget.click |
A click on a selector you named |
app.widget.click.frustration |
A dead click or a rage click |
app.jank |
A long task, period and threshold in seconds |
browser.page_engagement |
Scroll depth, content depth, and how long the view lasted |
session.start / session.end |
Session lifecycle, opt-in |
Names come from the OpenTelemetry browser and app.* conventions, so a
dashboard built on them covers web and mobile without knowing which it is
reading.
Frustration signals
Section titled “Frustration signals”A dead click is a bug report nobody filed, and it is the one browser signal no tracing backend can produce on its own: a click that does nothing runs no code, issues no request, and opens no span. The trace is empty exactly when the user is most stuck.
initFull({ service: 'web', endpoint, captureFrustration: true, // or { deadClicks: {...}, rage: false }});Both detectors are heuristics and the thresholds are the design. Liveness
suppresses first — a DOM mutation under 2500ms, a scroll or selectionchange
under 100ms, a visibility or focus change within a second either side — and only
then does a timeout convict. Anchors, modifier-key clicks and repeat clicks on
one node never become candidates. Loosening any of it produces “dead clicks” on
working buttons, which teaches people to ignore the signal.
Page engagement
Section titled “Page engagement”Scroll depth and content depth are different questions. Scroll depth is how far the reader moved; content depth is how far down the page has been on screen. On a page shorter than the window nothing scrolls, so a scroll-only reading says 0% and the visit looks like a bounce when every word was visible.
initFull({ service: 'web', endpoint, captureEngagement: true });Reported on pagehide and on history navigation, so a single-page app gets one
report per route rather than one per visit.
Breadcrumbs
Section titled “Breadcrumbs”An exception on its own is a stack and a shrug. breadcrumbs keeps a
byte-bounded trail of what happened first and attaches it to the error as
exception.breadcrumbs:
initFull({ service: 'web', endpoint, breadcrumbs: { console: true, clicks: true, maxBytes: 32_768 },});Bounded in bytes rather than entries — one enormous crumb is not an entry-count problem — and the newest step is never dropped, since it is the one nearest the error.
Console as log records
Section titled “Console as log records”initFull({ service: 'web', endpoint, captureConsoleLogs: { minLevel: 'warn' },});Console output becomes OTLP log records on the exporter’s existing transport, so
it inherits the retries, the offline queue and the session id. The real console
is always called first: a telemetry failure must never be why a developer’s
console.log did not appear.
Distinct from breadcrumbs, which keep the same output on an exception for whoever reads the error. Turning both on is reasonable.
Sessions and sampling
Section titled “Sessions and sampling”session.id is stamped on every span, event and log, so a visit’s navigation,
fetches, vitals, clicks and errors reassemble into one journey. A gap longer than
timeoutMs (30 minutes) starts a new session, linked by session.previous_id.
initFull({ service: 'web', endpoint, sampleRate: 0.1, session: { emitEvents: true },});sampleRate is decided by hashing the session id, not by a coin flip per record.
Math.random() < 0.1 keeps a tenth of every session — enough to draw a chart,
never enough to answer a support ticket. Hashing keeps all of a tenth of the
visits, across spans, events and logs alike. Raising the rate mid-incident only
ever adds sessions; the ones you were already watching stay in the set.
Remote config
Section titled “Remote config”The sampling rate that was fine last week is drowning the collector; a browser update has started throwing an error nobody can fix and it is burying everything else. Both are one-line changes that otherwise wait for a deploy.
initFull({ service: 'web', endpoint, remoteConfigUrl: '/autotel.json' });A JSON file at a URL you already serve. The last good config is read back synchronously at startup so the first records of a visit already obey it, and the refresh happens behind them; a fetch that fails changes nothing. The file is untrusted input, so only known keys with valid values survive parsing:
{ "sampleRate": 0.25, "captureDeadClicks": false, "captureRageClicks": true, "captureEngagement": true, "errorSuppression": [ { "key": "value", "operator": "contains", "value": "ResizeObserver" } ]}Capture toggles let remote win both ways — a toggle that can only say “off” is not a control. Error suppression is additive only: a fetched file must never be able to switch off error reporting the application asked for.
Privacy Controls
Section titled “Privacy Controls”import { init } from 'autotel-web';
init({ service: 'my-app', privacy: { allowedOrigins: ['https://api.example.com'], respectDoNotTrack: true, respectGPC: true, },});Framework Integration
Section titled “Framework Integration”Next.js
Section titled “Next.js”// app/layout.tsx (client component)'use client';import { init } from 'autotel-web';init({ service: 'my-nextjs-app' });import { useEffect } from 'react';import { init } from 'autotel-web';
function App() { useEffect(() => { init({ service: 'my-react-app' }); }, []); return <div>...</div>;}PostHog
Section titled “PostHog”joinPostHog(posthog) from autotel-posthog stamps PostHog’s session id onto
spans and copies it onto same-origin fetches as W3C baggage. Lean mode can take
the id directly:
import { init } from 'autotel-web';import { posthogSessionId } from 'autotel-posthog';
init({ service: 'web', session: { id: posthogSessionId } });See PostHog.
Examples
Section titled “Examples”example-web-vanilla: Vanilla JS withautotel-weblean mode showing automatictraceparentinjection.example-nextjs: Next.js withautotel-webfor browser-to-backend distributed tracing.example-tanstack-start: TanStack Start withautotel-webfor client-side trace propagation.example-posthog: Browser join plus a checkout server that readssession.idfrom baggage.
Why Not Full OTel in the Browser?
Section titled “Why Not Full OTel in the Browser?”- Full
@opentelemetry/sdk-trace-webis ~700KB autotel-weblean mode is 1.6KB (440x smaller)- The browser only needs to propagate context: the backend does the tracing
Browser AI
Section titled “Browser AI”- Chrome Built-in AI: spans for the on-device model your page runs.
- WebMCP: spans for the tools your page offers a browser agent.
Entry Points
Section titled “Entry Points”autotel-web: Lean mode (default, ~1.6KB)autotel-web/full: Full mode (~40-50KB)autotel-web/privacy: Privacy managerautotel-web/baggage:setBaggage/clearBaggagewithout the rest of the SDK