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 bundled, zero-dependency RUM script:
<head>
@telemetryBrowser
...
</head>
That script (served from your app, cached) roots the browser trace on the
server trace, records a document.load span, instruments fetch
(propagating traceparent to same-origin calls so backend spans join
the trace — cross-origin is skipped to avoid CORS preflight), captures
uncaught JS errors as error spans, and reports Core Web Vitals. Tune it in
config:
'ingest' => ['spans' => [
'browser' => [
'fetch' => true, // instrument fetch + propagate traceparent
'errors' => true, // capture uncaught errors
'vitals' => true, // report LCP/CLS/INP as a web-vitals span
'sample' => 1.0, // client-side head sampling (0–1)
],
]],
Web Vitals (vitals): via PerformanceObserver, where supported —
web_vitals.lcp_ms (Largest Contentful Paint), web_vitals.cls
(Cumulative Layout Shift), web_vitals.inp_ms (a simplified
Interaction to Next Paint — the worst single interaction observed, not
the spec's high-percentile-across-all-interactions). LCP and CLS are not
final until the page starts to close, so they ship as one web-vitals
span at page hide/unload, alongside the final flush — not on load.
Publish it to your own build/CDN instead of serving it live:
php artisan vendor:publish --tag=telemetry-assets.
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 endpoint contract
POST {path} with Content-Type: application/json:
{
"spans": [
{
"traceId": "0af7651916cd43dd8448eb211c80319c",
"spanId": "b7ad6b7169203331",
"parentSpanId": "0020000000000001",
"name": "document.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: 'document.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.