Skip to content

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_load span 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_vital span each (web_vital.name, web_vital.value, and web_vital.rating: good / needs-improvement / poor), measured by Google's web-vitals library.
  • fetch and XHR: a client span per call. traceparent goes 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 exception spans, with the stack trace and a fingerprint (exception.group).
  • Route changes: history navigations as browser.route_change.
  • Livewire wire:navigate and Inertia visits: one browser.navigation span per soft navigation. Later requests join the new page's server trace (Livewire through the new page's traceparent meta, Inertia through the traceresponse header).
  • Server-Timing: when traces.server_timing is on, the header's metrics land on the page-load and fetch/XHR spans as server_timing.<name>.duration_ms.
  • Analytics: with telemetry.analytics on, 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) and spanId (16 hex) are required; parentSpanId optional. start/end are 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.