Sazabi Browser SDK
Capture browser sessions — navigation, clicks, errors, and network calls with trace context — with the Sazabi browser SDK.
Capture what happens in your users' browsers with @sazabi/browser, the official Sazabi browser SDK. It emits a session-scoped stream of navigation, interactions, console output, JavaScript errors, and network calls as OTLP log records, and injects W3C trace context into your API requests so a frontend symptom and its backend cause share a trace id.
About
@sazabi/browser is a zero-dependency browser library you install in your own web app. It lets you:
- Follow a single visitor's session across tabs, from first page load to error
- Catch uncaught exceptions and unhandled promise rejections with stack traces
- See rage clicks and dead clicks — the interactions that signal frustration
- Join frontend network calls to backend logs on a shared
trace_id - Use AI to investigate a browser session end to end
The SDK is configured with your intake URL alone. The URL embeds a write-only public key, which is why it is safe to ship in a browser bundle: it can write telemetry into your project and never read from it.
What gets captured
Every event carries session.id, session.window_id, session.distinct_id, web.page.url, and a web.event_type naming the kind of event:
web.event_type | What it records |
|---|---|
navigation | Page loads and in-app route changes, including programmatic pushState navigation |
click, rage_click, dead_click | Element clicks and the repeated or no-op clicks that signal frustration |
input | One event per field-interaction episode — which field, never what was typed |
network | fetch and XMLHttpRequest calls with method, status, duration, and the injected trace context |
error | Uncaught exceptions and unhandled rejections, with exception.type, exception.message, and exception.stacktrace |
log | Lines your app emits through the SDK's log() |
custom | Marks you emit with addEvent() |
By default the SDK also mirrors console.error and console.warn into the stream, leaving the original console behavior untouched.
Session replay recordings and Core Web Vitals are not captured.
Configuration
| Option | Default | Purpose |
|---|---|---|
network.capture | true | Emit network events for fetch/XHR |
network.propagateTraceContext | true | Inject traceparent into eligible requests |
network.allowlist | same-origin only | Origins eligible for header injection |
network.sampledFlag | false | Mark minted traces sampled (-01) for backend tracers |
network.resourceFallback | true | Observe requests that bypass instrumentation |
network.requestIdHeaders | common platform headers | Response headers probed for a request id |
console.capture / console.levels | true, ["error", "warn"] | Console mirroring |
input.capture | true | Field-interaction episodes |
consent | off | Hold all capture until it resolves true |
flushIntervalMs | 5000 | Batch flush cadence |
Privacy controls
- Input values are never captured — not even value lengths. An
inputevent records which field was engaged, for how long, how many edits, and whether content was pasted; never keystrokes or contents. Request and response bodies are never captured either. - Element names come from developer-authored sources only —
aria-label,<label>,placeholder, and button captions — and only for interactive elements. Clicks on plain containers, where user content lives, record selectors only. - Mask or rename any element. Add
data-sazabi-maskto suppress text capture for an element's whole subtree, ordata-sazabi-nameto set its reported name explicitly. - Consent gating. Pass
consent: () => boolean | Promise<boolean>and the SDK installs no listeners, no patches, and makes no network calls until it resolves true.identify()calls made while consent is pending are buffered and applied when capture starts. - Client-asserted identity.
session.distinct_idis whatever the browser claims. Treat it as a claim, not as proof of identity — the same is true of every field on these events, since the intake URL is extractable from any page that ships it. - Reversible by construction. Every patch is transparent, idempotent, and reversible; SDK failures are invisible to your app, and
shutdown()restores the original functions.
Prerequisites
- A web app you can add an npm dependency to and redeploy.
- A Sazabi project.
Set up in the dashboard
Open the log source
Go to Settings > Log streams, choose Sazabi Browser SDK, and click Add.
Copy your intake URL
Sazabi shows your complete intake URL at the top of the setup screen. That URL is all the SDK needs — pass it as intakeUrl, with or without the /v1/logs path.
Install the SDK
npm install @sazabi/browserInitialize in your entry module
Import the register entrypoint on the first line of your app's entry
module (src/main.tsx, src/index.ts, app/layout.tsx), then call
init():
// Import first, because a module that runs earlier can capture the native
// fetch/XHR/history references before the SDK wraps them.
import "@sazabi/browser/register";
import { init } from "@sazabi/browser";
init({
intakeUrl: "<your intake URL>",
serviceName: "my-web-app",
serviceVersion: process.env.GIT_SHA,
environment: "production",
});The register import installs dormant instrumentation before any other
module can capture the native fetch, XMLHttpRequest, and history
references. It is optional but recommended: init() alone also
instruments, but only at call time, and every static import in your entry
file evaluates before its first statement — so an auth client or HTTP
wrapper constructed at module scope wins that race.
Requests that slip past instrumentation are still observed through a
resource-timing fallback, and the SDK emits a once-per-origin
instrumentation_gap diagnostic naming the escaped origin, so the gap is
visible rather than silent.
init() is idempotent, never throws into your app, and no-ops during
server-side rendering, so it is safe in a shared entry module.
Correlate with your backend
Same-origin requests get a traceparent header by default. If your API is
on another origin, add it to the network allowlist:
init({
// …
network: {
allowlist: ["https://api.example.com"],
},
});Your API's CORS configuration must include traceparent in
Access-Control-Allow-Headers, or the browser preflight fails and those
requests break. Your backend also has to run each request inside the
incoming trace context for its log lines to share the same trace_id. If
your backend runs OpenTelemetry, that extraction already happens by
default; set network: { sampledFlag: true } if you also want your
backend tracer to record those traces — the log join works either way.
The SDK additionally records platform request ids echoed on responses
(x-request-id, cf-ray, x-vercel-id, and similar) as
web.network.request_id, giving you an exact join key against CDN and
platform logs with no backend changes. Reading those cross-origin
requires the header in Access-Control-Expose-Headers.
Attribute sessions to users (optional)
import { addEvent, identify, log, reset } from "@sazabi/browser";
identify("user_123", { plan: "pro" }); // on sign-in — internal id, not an email
addEvent("checkout_started", { cartValue: 42 }); // custom mark
log("ERROR", "checkout failed", { orderId }); // app log line
reset(); // on sign-outreset() rotates the session and window ids, so later activity on the same
device never threads into the previous user's timeline. Switching
identities without a reset() rotates automatically.
Set up with the CLI
You can also register the Sazabi Browser SDK log source with the Sazabi CLI (installed and authenticated — see CLI reference).
Registering the source mints the same intake URL the dashboard shows above — the only value the SDK needs:
sazabi log-sources create sazabi_browser_sdk --mode connectionlessVerify
-
Use your app: load it and click through a few pages. The SDK flushes every 5 seconds, and again when the tab is hidden or closed, so events land within about 10 seconds.
-
Check the browser: in the network tab, filter for
intake— POSTs to/v1/logsshould return 200. -
Confirm arrival: open the Intake page in the Sazabi dashboard and confirm records from the Sazabi Browser SDK source appear, or ask the agent in a thread: "Show me recent browser sessions."
Troubleshooting
No requests to intake at all — Confirm init() runs before your framework mounts. Check the browser console for the [@sazabi/browser] configuration warning, which means intakeUrl or serviceName was empty. If you pass a consent callback, confirm it resolves true.
Requests return 401 — The intake URL's embedded key was deactivated, or the URL belongs to another project. Check the log stream in Settings > Log streams and copy the current intake URL.
Some requests are missing from the stream — Another library captured native fetch before the SDK did. Move import "@sazabi/browser/register"; to the true first line of your entry module. The SDK falls back to resource-timing data for these requests and emits a one-time instrumentation-gap diagnostic per origin.
Network events have no trace id — traceparent is injected only into same-origin requests and origins you added to network.allowlist. For cross-origin APIs, confirm the response allows the traceparent request header in CORS.
Stream shows Inactive — Not an error: it means no telemetry arrived in the last hour. If you expected telemetry, check that the build calling init() has deployed and someone has loaded the app since, that a consent gate is not resolving false, and that build-time configuration did not inline an empty string for intakeUrl (init() warns once in the console and disables itself).
Log stream states
Each log stream for this source has a status visible in Settings > Log streams, derived from recent telemetry activity on the log stream's key:
| Status | Meaning |
|---|---|
| Active | Telemetry has been received from this log stream in the last hour. |
| Inactive | The stream is live, but no telemetry has been received in the last hour. Expected before your first telemetry arrives, or whenever the source is idle. |
| Paused | You paused the log stream, so it no longer accepts telemetry. Historical telemetry already received remains visible. Resume it from the log stream's action menu — the same intake URL starts accepting again, with no reconfiguration. |