Telemetry Browser
@cboxdk/telemetry-browser
Deep browser RUM, correlated with your backend traces. The frontend
companion to cboxdk/laravel-telemetry:
page load, Web Vitals, fetch/XHR, uncaught errors and SPA route changes —
shipped to your app's ingest endpoint and joined to the backend trace via
W3C traceparent. One trace id, one waterfall: click → browser span →
request span → queries → jobs.
- ~9.5 KB gzipped (Web Vitals included), zero config to get started.
- No
@opentelemetry/*— a lean, tailored SDK that matches the ingest endpoint exactly and emits the same dimensions as the backend, socboxdk/laravel-telemetry-uicross-links frontend and backend out of the box. - Best-effort by design: never throws into your app.
Status: alpha. APIs may shift before 1.0.
Install
npm i @cboxdk/telemetry-browser
import { init } from '@cboxdk/telemetry-browser';
const telemetry = init({
endpoint: '/telemetry/spans',
serviceName: 'my-app',
environment: 'production',
release: 'a1b2c3d',
traceparent: document.querySelector('meta[name=traceparent]')?.content, // root on the server trace
user: () => window.currentUser?.id, // an id, never a name/email
});
Or drop the standalone build in a <script> — it auto-inits from its own
data-* (the exact contract the Laravel package's @telemetryBrowser
directive emits):
<meta name="traceparent" content="00-...-...-01">
<script src="/telemetry/browser.js" defer
data-endpoint="/telemetry/spans"
data-service="my-app" data-environment="production"
data-user-id="42"></script>
Using the standalone build's handle
The auto-started handle (the same object init() returns) is
window.Telemetry.client. A script that may run before the SDK loads
(an inline <script>, a deferred tag) pushes a callback onto
window.TelemetryQueue instead. Queued callbacks run in order once the
SDK has started; after that, push() runs a callback at once, so the same
line works either way:
<script>
(window.TelemetryQueue = window.TelemetryQueue || []).push((telemetry) => {
telemetry.setUser('42');
telemetry.track('signup_completed', { plan: 'pro' });
});
</script>
- A callback that throws is skipped; the rest still run.
- On a page that is sampled out (
data-sample), there is no client:Telemetry.clientstaysundefinedand queued callbacks never run. init()runs once per page. A second call, fromwindow.Telemetry.init, your own bundle, or the script tag run again by Livewire'swire:navigate, returns the first handle and ignores its config (debug: truelogs a warning), so fetch/XHR, history and the error listeners are never patched twice. The sampling decision is not re-rolled either. Without adata-endpointnothing auto-starts, and callingwindow.Telemetry.init({...})yourself publishes the client and runs the queue the same way.
data-* |
Option |
|---|---|
data-endpoint |
endpoint (required to auto-start) |
data-service, data-environment, data-release |
serviceName, environment, release |
data-user-id (or data-user) |
user |
data-session |
session |
data-sample |
sampleRate |
data-trace-header |
traceIdHeader |
data-analytics="1", data-debug="1" |
analytics, debug |
data-fetch, data-xhr, data-errors, data-navigation, data-frameworks, data-vitals, data-server-timing |
instrument.* ("0" turns one off) |
The traceparent comes from <meta name="traceparent">.
The dimension contract
Every browser span carries these — aligned with the backend where they
overlap, so a browser span and the backend span it triggered filter by the
same keys. Exported as ATTR for the UI to align against.
| Dimension | Key | On |
|---|---|---|
| Service / env / release | service.name, deployment.environment.name, service.version |
every span |
| User (id only) | user.id |
every span |
| Session | session.id |
every span |
| Browser marker | browser (true) |
every span |
| URL | url.path, url.full, http.url |
span-dependent |
| HTTP | http.request.method, http.response.status_code |
fetch/XHR |
| Device / client | user_agent.original, browser.language, browser.viewport, network.connection.type, device.memory_gb |
page load |
| Page timing | browser.ttfb_ms, browser.fcp_ms, browser.dom_interactive_ms |
page load |
| Server-Timing | server_timing.<name>.duration_ms, server_timing.<name>.description |
page load, fetch/XHR |
| Web Vitals | web_vital.name, web_vital.value, web_vital.rating |
vital markers |
| Errors | exception.type, exception.message, exception.file, exception.line, exception.column, exception.group, exception.stacktrace |
error spans |
Span names are a small bounded set (SPAN): browser.page_load,
browser.route_change, browser.web_vital, exception, plus
fetch <METHOD> / xhr <METHOD>.
What it captures
- Page load — a
browser.page_loadspan with navigation timing (TTFB, DOM interactive/complete, transfer size) + device dimensions. - Web Vitals — LCP, CLS, INP, FCP, TTFB as marker spans with Google's
good/needs-improvement/poor rating (via the tiny
web-vitalslib). - fetch & XHR — a client span per call, with
traceparentpropagated to same-origin requests so the backend continues the trace (cross-origin is left untouched to avoid CORS preflight). - Server-Timing — the backend's
Server-Timingmetrics on the spans they describe (see below). - Errors — uncaught errors and unhandled promise rejections as
exception spans, matching the backend's
exception.*keys. - SPA route changes —
historypushState/replaceState + popstate asbrowser.route_changespans. - Livewire
wire:navigateand Inertia visits — onebrowser.navigationspan per soft navigation, and later requests join the new page's server trace (see below).
Toggle any of them via instrument: { fetch: false, ... }.
Livewire & Inertia soft navigations
Detected automatically from the frameworks' own events; nothing to
configure. Once a framework actually navigates (a livewire:navigate, or
any Inertia router event), it owns navigation and the generic history
handler stops recording, so a navigation is never counted twice (the
framework's own pushState would otherwise look like a second one). A
Livewire page that never uses wire:navigate keeps generic tracking.
Livewire 3 / 4 (wire:navigate) |
Inertia v1 / v2 / v3 | |
|---|---|---|
| Span starts | livewire:navigate |
inertia:start |
| Span ends | livewire:navigated |
inertia:finish |
| Failed | no finish event: closed as timeout after 30 s |
inertia:exception/invalid (v1/v2), inertia:httpException/networkError (v3) |
| Back/forward | livewire:navigate with history: true |
inertia:navigate without a visit, right after a popstate |
| Prefetch-cache click | the navigation itself; the fetch happened earlier | inertia:navigate without a visit (cached) |
| Ignored | a livewire:navigate another listener prevented |
prefetches, async visits, router.reload(), partial reloads (only/except/reset) of the current page |
Each browser.navigation span carries browser.navigation.framework,
browser.navigation.outcome (completed/cancelled/interrupted, or
timeout when no finish event came within 30 s, e.g. a failed Livewire fetch),
browser.route.from, url.path/http.url of the new page,
browser.navigation.history, browser.navigation.cached, and for Livewire
browser.navigation.fetch_ms (from livewire:navigate until the page HTML
was in hand; the rest is the swap). An analytics page view is emitted once
per completed navigation that changed the path. A page request that starts
during the navigation is its child. Inertia visits work that way;
Livewire link clicks usually don't (see Limits).
Which trace later requests join (recorded as
browser.navigation.trace_source):
- Livewire (
meta) —wire:navigatefetches the full page and swaps<head>, so the new page's<meta name="traceparent">(from@telemetryTraceparent/@telemetryBrowser) names the new page's server span. Later fetch/XHR spans parent to it, exactly as after a full page load. - Inertia gets JSON, so there is no meta:
traceresponse— a W3Ctraceresponseheader on the page response is adopted (trace and span id).navigation— otherwise, later requests parent to the navigation span itself, in the same trace. This is also the fallback for Livewire when the new page has no meta. The page request carried ourtraceparent, so a backend that continues incoming traces (laravel-telemetry's default) put its request span under that navigation too.none— if theX-Trace-Idheader (traceIdHeader; laravel-telemetry'straces.response_header) shows the server started a different trace, the SDK can't join it without a span id. The trace stays put and the server's id is recorded asbrowser.navigation.server_trace_id.
- A meta or
traceresponsewhose sampled flag is00is not adopted: the server didn't record that span, so children would hang off nothing. The navigation span is used instead. Propagatedtraceparentheaders carry the server's sampling flag rather than always01. - Cached restores, cancelled/interrupted/failed visits never move the trace.
telemetry.traceId()follows: it returns the active trace after a soft navigation moved it.
Limits:
- Livewire fetches a link's page on mousedown (or on hover with
wire:navigate.hover) and fireslivewire:navigateonly on release. For link clicks the page request therefore usually starts before the navigation span and is not its child, andfetch_msis a lower bound on the fetch time. The new meta is still adopted.Livewire.navigate()and uncached back/forward fetch after the event, so there the request is a child. - A Livewire cached back/forward restore leaves later requests on the trace you navigated away from, not the restored page's old one: that page's server span ended long ago.
- Web Vitals and the page-load span stay on the document's original trace; they describe the hard load.
- Inertia: which response fetched the page is matched by path plus the
X-Inertiaresponse header, and needs thexhr(axios) orfetchinstrumentation enabled to read headers. Without either, later requests still parent to the navigation span. - Put
@telemetryBrowser(or your SDK<script>) in<head>. The standalone build initialises once per document, so if Livewire runs it again from<body>that second run is a no-op.
Server-Timing
When the backend sends a Server-Timing header (laravel-telemetry can, opt
in), the SDK puts each metric on the span for that response:
Server-Timing: db;dur=12.3;desc="3 queries", app;dur=48.1
→ server_timing.db.duration_ms = 12.3
server_timing.db.description = "3 queries"
server_timing.app.duration_ms = 48.1
- Page load: read from the navigation entry
(
performance.getEntriesByType('navigation')[0].serverTiming). - fetch / XHR: parsed from the response's own
Server-Timingheader, so it is exact even when the same URL is requested many times. - Names are lowercased and sanitized to
[a-z][a-z0-9_]*(DB-Read→db_read); at most 10 metrics per span; a repeated name keeps its first entry. A metric with nodurrecords0;descriptiononly when set. - Cross-origin fetch/XHR: the browser hides the header unless the
server sends
Access-Control-Expose-Headers: Server-Timing. The SDK reads the header, not resource timing entries, soTiming-Allow-Originis not needed. Same-origin needs nothing. - Turn it off with
instrument: { serverTiming: false }(data-server-timing="0").
Source maps (symbolication)
Minified stacks (app-abc.js:1:2481) group and read poorly, and the file
names shift every deploy. Upload your build's source maps keyed by the
same release you pass to init({ release }), and the backend resolves
each browser stack back to original source/line/column/name on read — so
frontend error grouping and detail get as good as the backend's.
Upload from CI (which can hold the secret — the browser never does):
npx telemetry-sourcemaps \
--dir dist/assets --release "$GIT_SHA" \
--endpoint https://app.example.com/telemetry/sourcemaps \
--token "$TELEMETRY_SOURCEMAPS_TOKEN"
Or as a Vite plugin, so maps upload on every production build:
// vite.config.ts
import { telemetrySourcemaps } from '@cboxdk/telemetry-browser/sourcemaps';
export default defineConfig({
plugins: [
telemetrySourcemaps({
endpoint: 'https://app.example.com/telemetry/sourcemaps',
release: process.env.GIT_SHA,
token: process.env.TELEMETRY_SOURCEMAPS_TOKEN,
}),
],
});
The Laravel package enables the receiving endpoint with
TELEMETRY_SOURCEMAPS=true + TELEMETRY_SOURCEMAPS_TOKEN (token-gated,
secure by default) and symbolicates at read time via its Symbolicator
service — see the
browser tracing guide.
The uploader is Node-only and never enters the browser bundle. Maps stay
private: they're resolved server-side on demand, never shipped to clients.
Until maps exist for a release, browser errors still group — by
exception.group, a deploy-stable fingerprint (exception.type + a
normalized message, since minified file:line moves every build). Source
maps make the detail readable; grouping works with or without them.
Manual API
telemetry.record({ name: 'checkout.step', attributes: { step: 2 } });
telemetry.error(err, { 'order.id': id });
telemetry.setUser(user.id); // after login
telemetry.setAttributes({ team: 'acme' });
telemetry.track('signup_completed', { plan: 'pro' }); // custom analytics event (needs analytics:true)
telemetry.traceId(); // correlate your own logs
telemetry.flush();
Configuration
| Option | Default | |
|---|---|---|
endpoint |
— | the ingest URL (required) |
serviceName / environment / release |
— | identity dimensions |
user |
— | id or () => id |
traceparent |
— | server trace to root on |
session |
— | server-provided session.id to share one visit key (set by @telemetryBrowser when analytics is on) |
attributes |
{} |
extra global dimensions |
sampleRate |
1 |
head sampling, decided once per page |
analytics |
false |
unsampled analytics channel: SPA page views, engagement, track() |
instrument |
all on | per-instrumentation toggles (frameworks: false turns off Livewire/Inertia handling, serverTiming: false stops reading Server-Timing) |
traceIdHeader |
X-Trace-Id |
the backend's trace-id response header (data-trace-header); '' ignores it |
maxSpans |
128 |
buffer cap before a forced flush |
Spans batch and ship with sendBeacon on visibilitychange/pagehide,
when the buffer fills, and on a 15 s heartbeat.
Security
The SDK sends only what you configure + the dimensions above. The server enforces the trust boundary (throttling, payload bounding, sampling, optional auth) — see the Laravel package's browser tracing guide. Provide a user id, never a name or email.
License
MIT © Cbox