Sazabi
Log sourcesSend to an endpoint

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_typeWhat it records
navigationPage loads and in-app route changes, including programmatic pushState navigation
click, rage_click, dead_clickElement clicks and the repeated or no-op clicks that signal frustration
inputOne event per field-interaction episode — which field, never what was typed
networkfetch and XMLHttpRequest calls with method, status, duration, and the injected trace context
errorUncaught exceptions and unhandled rejections, with exception.type, exception.message, and exception.stacktrace
logLines your app emits through the SDK's log()
customMarks 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

OptionDefaultPurpose
network.capturetrueEmit network events for fetch/XHR
network.propagateTraceContexttrueInject traceparent into eligible requests
network.allowlistsame-origin onlyOrigins eligible for header injection
network.sampledFlagfalseMark minted traces sampled (-01) for backend tracers
network.resourceFallbacktrueObserve requests that bypass instrumentation
network.requestIdHeaderscommon platform headersResponse headers probed for a request id
console.capture / console.levelstrue, ["error", "warn"]Console mirroring
input.capturetrueField-interaction episodes
consentoffHold all capture until it resolves true
flushIntervalMs5000Batch flush cadence

Privacy controls

  • Input values are never captured — not even value lengths. An input event 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 onlyaria-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-mask to suppress text capture for an element's whole subtree, or data-sazabi-name to 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_id is 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/browser

Initialize 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-out

reset() 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 connectionless

Verify

  1. 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.

  2. Check the browser: in the network tab, filter for intake — POSTs to /v1/logs should return 200.

  3. 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 idtraceparent 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:

StatusMeaning
ActiveTelemetry has been received from this log stream in the last hour.
InactiveThe 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.
PausedYou 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.

Further reading