Browser Telemetry

Collect replayable browser events in a JSON stream and replay or tail them by offset.

Browser telemetry is a natural fit for an application/json stream: the browser appends ordinary JSON over HTTP, Ursula stores each event as one message in commit order, and consumers replay or tail events from an offset.

The runnable example is in examples/browser-telemetry.

Data model

Keep one timestamp: the time captured by the browser. It remains application data and may arrive out of order after offline buffering or retries.

{
  "captured_at": "2026-07-18T10:30:00.100Z",
  "type": "network_response",
  "status": 500
}

Ursula does not reorder the stream by captured_at and does not invent a second protocol timestamp. Commit order and offsets are the stream's only coordinates: an out-of-order captured_at never changes the committed order, the stored bytes or any offset. Event time is therefore not a stream read parameter (there is no ?captured_at= read). A consumer that needs a time window reads by offset and filters on captured_at itself.

Browser collector

Point plain fetch at a same-origin route that proxies to the Ursula stream. The gateway owns browser authentication and CORS policy.

const events = [{
  captured_at: new Date().toISOString(),
  type: "navigation",
  duration_ms: performance.getEntriesByType("navigation")[0]?.duration,
}];

const response = await fetch("/telemetry/events", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(events),
  keepalive: true,
});

console.log(response.headers.get("Stream-Next-Offset"));

One array append stores one message per element. A successful response returns the stream's new tail in Stream-Next-Offset.

Replay and live tail

Read events from the start of the stream:

curl -i 'http://127.0.0.1:4437/telemetry/events?offset=-1'

The body is NDJSON, one event per line. A response returns at most 8 MiB and can end mid-line: buffer any incomplete trailing line and continue from the response's Stream-Next-Offset; Stream-Up-To-Date: true means the read reached the tail. Offsets are opaque: store and pass back the values Ursula returns, and compare them only with each other. For a live native EventSource, add live=sse.

OpenTelemetry (OTLP-JSON)

OpenTelemetry data exported as OTLP-JSON can go straight into a stream. Create one application/json stream per service, signal and day, and point the stock collector's otlphttp exporter at it:

exporters:
  otlphttp/ursula:
    traces_endpoint: http://ursula-gateway:4437/otel/traces-checkout-20261003
    encoding: json
    compression: none
processors:
  batch:
    send_batch_max_size: 2048

Ursula does not decompress request bodies, so keep compression: none, and keep batches well under the 32 MiB request limit. Each export request is stored as one message. Replay and tail the stream by offset as above.