Browser tracing (RUM)
Browser tracing — end-to-end distributed tracing
The backend already continues an incoming W3C traceparent, so if the
browser propagates its trace context on fetch/XHR, your backend spans are
already children of the browser's trace. What was missing is a place for the
browser to ship its own spans (page load, resource timings, JS errors).
This package can be that place — the Laravel app is the collector for its own frontend. Enable the optional ingest endpoint and browser + backend spans land in Tempo under one trace id: click → browser span → request span → queries → jobs, all in a single waterfall.
Enable it
TELEMETRY_INGEST_SPANS=true
# TELEMETRY_INGEST_SPANS_PATH=telemetry/spans # default
The endpoint is off by default and, when on, protected by throttling,
strict payload bounding and optional head sampling — never a bearer token
(a browser can't hold a secret). Tune it in config/telemetry.php:
'ingest' => ['spans' => [
'enabled' => env('TELEMETRY_INGEST_SPANS', false),
'path' => 'telemetry/spans',
'middleware' => ['throttle:300,1'], // add 'auth' etc. if the app is behind login
'max_spans' => 128, // per batch; excess dropped
'max_attributes' => 32, // per span
'sample_rate' => 1.0, // head sampling (0–1) to cap volume
]],
Turnkey: one directive
Add @telemetryBrowser to your layout <head> and you're done. It emits
the traceparent meta and a <script> tag for the
@cboxdk/telemetry-browser
SDK. The SDK is vendored in this package (about 9.5 KB gzipped) and served
from your app: no CDN, nothing to build.
<head>
@telemetryBrowser
...
</head>
Keep it in <head>. Livewire re-runs <body> scripts on wire:navigate,
and the SDK initialises once per document.
What the SDK records:
- Page load: a
browser.page_loadspan rooted on the server trace, with TTFB, FCP, DOM interactive/complete, transfer size and device dimensions (user agent, viewport, connection type). - Web Vitals: LCP, CLS, INP, FCP and TTFB, one
browser.web_vitalspan each (web_vital.name,web_vital.value, andweb_vital.rating: good / needs-improvement / poor), measured by Google'sweb-vitalslibrary. - fetch and XHR: a client span per call.
traceparentgoes to same-origin calls only, so backend spans join the trace and cross-origin calls never trip a CORS preflight. - Errors: uncaught errors and unhandled rejections as
exceptionspans, with the stack trace and a fingerprint (exception.group). - Route changes:
historynavigations asbrowser.route_change. - Livewire
wire:navigateand Inertia visits: onebrowser.navigationspan per soft navigation. Later requests join the new page's server trace (Livewire through the new page's traceparent meta, Inertia through thetraceresponseheader). - Server-Timing: when
traces.server_timingis on, the header's metrics land on the page-load and fetch/XHR spans asserver_timing.<name>.duration_ms. - Analytics: with
telemetry.analyticson, SPA page views and engagement as unsampled events (see Analytics).
Every span carries session.id, url.path, and the service identity
from telemetry.service: service.name, deployment.environment.name,
and service.version when set. Set service.version to the release you
upload source maps for, and browser stacks resolve against them.
Tune it in config:
'ingest' => ['spans' => [
'browser' => [
'fetch' => true, // fetch spans + traceparent to same-origin calls
'xhr' => true, // the same for XMLHttpRequest (axios)
'errors' => true, // uncaught errors + unhandled rejections
'vitals' => true, // Web Vitals as browser.web_vital spans
'navigation' => true, // history route changes
'frameworks' => true, // Livewire / Inertia soft navigations (needs navigation)
'server_timing' => true, // read Server-Timing onto spans
'sample' => 1.0, // client-side head sampling (0–1), per page
'debug' => false, // console.debug the SDK's activity
],
]],
Each switch becomes a data-* attribute on the script tag, which is what
the SDK's standalone build reads: data-fetch, data-xhr,
data-errors, data-vitals, data-navigation, data-frameworks,
data-server-timing, data-sample, data-debug. The tag also carries
data-endpoint, data-service, data-environment, data-release,
data-trace-header (traces.response_header), and data-analytics plus
data-session when analytics is on.
Content Security Policy. If your app sets a nonce with
Vite::useCspNonce(), the script tag carries the same nonce. The
script is same-origin, so script-src 'self' covers it too.
Caching. The script is served with a one-day Cache-Control. Its URL
carries the SDK version (telemetry/browser.js?v=0.4.0), so an upgrade
reaches browsers on the next page view.
Publish it to your own build/CDN instead of serving it live:
php artisan vendor:publish --tag=telemetry-assets (again with --force
after upgrading the package), then point ingest.spans.asset_path at the
published copy.
Calling the SDK from your page. The snippet's SDK is
window.Telemetry.client once it has started. Code that may run before
the script loads pushes callbacks onto window.TelemetryQueue; they run
in order once the SDK has started, and a later push() runs at once:
<script>
(window.TelemetryQueue = window.TelemetryQueue || []).push((t) => {
t.setUser('{{ auth()->id() }}');
});
</script>
Browser spans carry the user as user.id, the same key the request spans
use.
Want the SDK in your own bundle instead, for manual spans? Run
npm i @cboxdk/telemetry-browser, call init() yourself, and use
@telemetryTraceparent for the meta. Loading both is harmless (the SDK
starts once per page), but the snippet's copy is then dead weight.
Prefer to roll your own? Use @telemetryTraceparent (just the meta) and
POST to the endpoint yourself. The contract and a minimal snippet are
below.
The bundled SDK version
This release ships @cboxdk/telemetry-browser 0.4.0. Three places
record it: Cbox\Telemetry\Http\BrowserSdk::VERSION, the banner at the
top of resources/js/browser.js, and resources/js/browser-sdk.json
(source commit, npm integrity, the file's sha256 and the bundled
web-vitals version). The SBOM lists the SDK and web-vitals as npm
components.
The bundle is the npm release's own dist/telemetry.global.js. The SDK's
release workflow builds it from the version's tag and publishes it with a
provenance attestation. To move to another release (maintainers):
composer browser-sdk -- 0.5.0 # download, check the sha512, rewrite the three files
composer sbom # picks the new version up from browser-sdk.json
Then add a CHANGELOG entry that names any span or attribute renames.
The endpoint contract
POST {path} with Content-Type: application/json:
{
"spans": [
{
"traceId": "0af7651916cd43dd8448eb211c80319c",
"spanId": "b7ad6b7169203331",
"parentSpanId": "0020000000000001",
"name": "browser.page_load",
"kind": "client",
"start": 1720000000000,
"end": 1720000000420,
"attributes": { "http.url": "https://app.test/dashboard" },
"status": "ok"
}
]
}
traceId(32 hex) andspanId(16 hex) are required;parentSpanIdoptional.start/endare epoch milliseconds (JS-friendly).- Invalid spans are dropped (never fatal); every value passes the
redaction engine before export, and
each span is stamped
browser: true. - Always returns
204.
Or roll your own
No SDK required — @telemetryTraceparent plus a few lines get you page-load + fetch spans:
const hex = n => [...crypto.getRandomValues(new Uint8Array(n))]
.map(b => b.toString(16).padStart(2, '0')).join('');
// Root the browser trace on the server trace when present.
const server = document.querySelector('meta[name=traceparent]')?.content;
const traceId = server ? server.split('-')[1] : hex(16);
const rootId = server ? server.split('-')[2] : hex(8);
addEventListener('load', () => {
const nav = performance.getEntriesByType('navigation')[0];
const t0 = performance.timeOrigin;
navigator.sendBeacon('/telemetry/spans', JSON.stringify({ spans: [{
traceId, spanId: hex(8), parentSpanId: rootId,
name: 'browser.page_load', kind: 'client',
start: t0 + nav.startTime, end: t0 + nav.loadEventEnd,
attributes: { 'http.url': location.href },
}] }));
});
// Propagate to the backend so its spans join this trace.
const origFetch = window.fetch;
window.fetch = (input, init = {}) => {
const spanId = hex(8);
init.headers = { ...(init.headers || {}), traceparent: `00-${traceId}-${spanId}-01` };
return origFetch(input, init);
};
For richer frontend instrumentation (component renders, route changes), the same endpoint accepts whatever spans you produce — or point the OpenTelemetry JS SDK's OTLP exporter at a collector if you prefer.
Source maps (symbolication)
Minified browser stacks (app-abc.js:1:2481) are useless on their own, and
the file names shift every deploy. Upload your build's source maps, keyed by
release, and stacks resolve back to original source/line/column/name —
so browser error grouping and detail get as good as the backend's.
Enable the upload endpoint (off, and secure, by default — it stores what it receives, so a token is required, never accidentally open):
TELEMETRY_SOURCEMAPS=true
TELEMETRY_SOURCEMAPS_TOKEN=a-long-random-secret # CI holds this, not the browser
Upload from your build pipeline. With
@cboxdk/telemetry-browser
it's one CLI call (or the bundled Vite plugin):
npx telemetry-sourcemaps \
--dir dist/assets --release "$GIT_SHA" \
--endpoint https://app.example.com/telemetry/sourcemaps \
--token "$TELEMETRY_SOURCEMAPS_TOKEN"
Each .map is POSTed as { release, name, map } with a Bearer token,
validated as a v3 map, size-capped (sourcemaps.max_bytes), and stored on
sourcemaps.disk under sourcemaps.prefix/<release>/<name>. Set the same
release on the SDK (init({ release })) so browser spans and their maps
line up. The Symbolicator service (a self-contained VLQ decoder — no ext,
no library) resolves stacks against them at read time:
app(\Cbox\Telemetry\Support\Symbolicator::class)
->symbolicateStack($release, $exceptionStacktrace);
// -> [['function' => 'checkout', 'file' => 'src/checkout.ts',
// 'line' => 42, 'column' => 7, 'original' => true], ...]
Symbolication is a read-time concern: the raw minified stack is stored
as-is on the exception span, and an issues UI (or your own tooling) resolves
it on demand via Symbolicator. Maps never have to be public.
Uploads never fail your build loudly and never throw into a request — a
missing or malformed map just leaves the frame minified (original: false).
Security notes
A world-reachable ingest endpoint is inherently abusable; the defenses are
throttle + payload bounding + head sampling, plus whatever middleware
you add (auth for a logged-in app, a signed URL, etc.). It never trusts the
client with a secret, caps every input, and clamps timestamps to a sane
window so replayed or clock-skewed batches are rejected. Leave it off unless
you want browser RUM.