# Web SDK

@cohorly/web is the browser SDK. It is built on the shared core with localStorage persistence, a fetch/sendBeacon transport (so events survive tab close and navigation), and optional SPA pageview autotracking.

## Install

```bash
pnpm add @cohorly/web
# or: npm install @cohorly/web
```

## Initialize

Call `init()` once at your app entry point. Pass your project `token` so
events route to the right project; `apiHost` defaults to the hosted Cohorly
API.

```ts
import { init } from "@cohorly/web";

const cohorly = init({
  token: "YOUR_PROJECT_TOKEN",
  trackPageviews: true,
});
```

After `init()` has run once, you can import the named `cohorly` singleton
anywhere instead of threading the returned instance through your app:

```ts
import { cohorly } from "@cohorly/web";

cohorly.track("Button Clicked", { button: "checkout" });
```

`init()` is SSR-safe: browser-only APIs (localStorage, `window`,
`sendBeacon`) fall back to in-memory/no-op implementations when
unavailable.

### Options

| Option                  | Type     | Notes                                                                                                                                                                                                                   |
| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiHost`               | string   | Optional. Ingestion API base URL. Defaults to the hosted Cohorly endpoint.                                                                                                                                              |
| `token`                 | string   | Project token stamped on every event and request.                                                                                                                                                                       |
| `trackPageviews`        | boolean  | Autotrack SPA pageviews.                                                                                                                                                                                                |
| `flushIntervalMs`       | number   | How often the queue auto-flushes.                                                                                                                                                                                       |
| `batchSize`             | number   | Flush once this many events are queued.                                                                                                                                                                                 |
| `superProperties`       | object   | Properties merged into every event from the start.                                                                                                                                                                      |
| `debug`                 | boolean  | Verbose logging.                                                                                                                                                                                                        |
| `loadFeatureFlags`      | boolean  | Evaluate feature flags on init. Default true.                                                                                                                                                                           |
| `sendExposureEvents`    | boolean  | Track `$feature_flag_called` on flag reads. Default true.                                                                                                                                                               |
| `flagPersistence`       | string   | `"networkOnly"`, `"networkFirst"`, or `"persistenceUntilNetworkSuccess"` (default). See [Feature flags](/feature-flags#flag-persistence).                                                                               |
| `flagCacheTtlMs`        | number   | How long the persisted flag cache stays valid, checked when the cache is adopted - adopted values keep serving until the next reload or identity change. Default 86400000 (24 h).                                       |
| `captureExceptions`     | boolean  | `true` captures unhandled errors and promise rejections automatically. Omitted: only `captureException()` sends. `false`: no `$exception` is sent at all, manual calls included. See [Error tracking](#error-tracking). |
| `release`               | string   | The release this build is. Sent as `$exception_release` on exceptions and as `$app_version_string` on every event.                                                                                                      |
| `dist`                  | string   | Distribution inside the release (e.g. a build hash), sent as `$exception_dist`.                                                                                                                                         |
| `beforeSendException`   | function | Scrub or drop an exception before it is queued.                                                                                                                                                                         |
| `maxExceptionQueueSize` | number   | Capacity of the exception queue. Default 50.                                                                                                                                                                            |
| `metrics`               | object   | `{ flagKey?: string }`. The feature flag that works as the kill switch for [custom metrics](#custom-metrics). Default `"cohorly_metrics"`.                                                                              |
| `sessionReplay`         | object   | `{ sampleRate?, onErrorSampleRate?, maskTextSelector?, blockSelector? }`. Records sessions with rrweb. See [Session replay](#session-replay).                                                                           |

## Track events

`track(event, properties?)` queues an event. The SDK automatically merges in
`$browser`, `$os`, `$current_url`, and screen size, plus `distinct_id`,
`time`, `$insert_id`, and `$lib`.

```ts
cohorly.track("Signed Up", { plan: "pro" });
cohorly.track("Item Added", { sku: "A-100", price: 29 });
```

## Identify

`identify(id)` switches the current user to a known id. The switch (distinct
id, persisted state) is applied synchronously, before any network call, so a
slow or offline request never delays it. On the first call after being
anonymous it then sends an `/alias` request so pre-login and post-login
activity link into one profile; the request is best effort - a failure
neither throws nor blocks, since the server also links the anonymous and
identified ids implicitly from the next event that carries both (see
[Identity](/reference/data-model#identity)).

```ts
await cohorly.identify("user_123");
```

Use `reset()` on logout to generate a fresh anonymous id. This matters on
shared devices: without it, the next person to use the device inherits the
previous person's anonymous history.

```ts
cohorly.reset();
```

## Super properties

Super properties are merged into every subsequent event. Register and
unregister them with `register()` / `unregister()`. Event-level properties
override super properties of the same key.

```ts
cohorly.register({ app_version: "1.2.3", plan: "free" });
cohorly.track("Viewed Page"); // includes app_version and plan
cohorly.unregister("plan");
```

## People profiles

Update the current user's profile through `cohorly.people`. These map to the
`/engage` endpoint.

```ts
await cohorly.people.set({ name: "Ada Lovelace", plan: "pro" });
await cohorly.people.setOnce({ first_seen: Date.now() });
await cohorly.people.increment({ logins: 1 });
await cohorly.people.unset(["temp_flag"]);
await cohorly.people.delete();
```

## Feature flags

Flags are evaluated on init by default and cached per identity (localStorage,
scoped by `distinct_id`, bounded by `flagCacheTtlMs`). The cache serves
immediately on the next launch until the first successful reload replaces it;
`flagPersistence` controls that policy - under the default
`"persistenceUntilNetworkSuccess"` an expired cache still serves up to 7
days stale, while `"networkFirst"` serves only inside the TTL.

```ts
const value = cohorly.getFeatureFlag("checkout-redesign", "control");
const enabled = cohorly.isFeatureEnabled("checkout-redesign", false);
const details = cohorly.getFeatureFlagDetails("checkout-redesign");
// details.source: "network" | "persistence" | "fallback"
```

A read that resolves to the fallback is a default, not an exposure. See
[Feature flags](/feature-flags#flag-persistence) for the persistence policies,
TTL, and source semantics.

## Custom metrics

`cohorly.metrics.count`, `gauge` and `distribution` have the same signature and
send the same `$metric` events as `@cohorly/core` (see
[Core SDK](/sdks/core#custom-metrics)): one event per series (metric name,
type, unit and tags) per 60 second window, never one per sample.

```ts
import { cohorly } from "@cohorly/web";

cohorly.metrics.count("checkout_started", 1, { plan: "pro" });
cohorly.metrics.gauge("queue_length", queue.length);
cohorly.metrics.distribution("js_cost.service.max_ms", elapsedMs, { service: "cart" });
cohorly.metrics.distribution("render_ms", elapsedMs, { tags: { page: "home" }, unit: "ms" });
```

Unlike the rest of `cohorly`, `metrics` does nothing before `init()` and never
throws, so a library can record without knowing whether the host app has
initialized the SDK. Pass `init({ metrics: { flagKey } })` to read the kill
switch from another flag.

- **Kill switch.** Deactivate the feature flag `cohorly_metrics` (or set its
  rollout to 0%) and the client stops recording and drops the window in
  progress. A missing flag means on.
- **Page hide.** The window in progress is queued and persisted on `pagehide`
  and when the page becomes hidden, and sent with `navigator.sendBeacon` when
  the browser and the queue allow it (one batch, under the beacon size limit,
  and no other flush running). Whatever stays behind is sent on the next visit.
  `optOutTracking()` discards the window.
- **Flags not loaded yet.** While the first flag evaluation is running and no
  flags are loaded, `$metric` events stay in the queue and are not sent. A
  disabled kill switch flag then drops them; otherwise they are sent. An
  evaluation that fails, or `loadFeatureFlags: false`, holds nothing back.

## Error tracking

Errors are ordinary events named `$exception`, so they can be broken down,
filtered and put into funnels like any other event. Capture one yourself with
`captureException(error, options?)`:

```ts
try {
  await submitOrder(cart);
} catch (error) {
  cohorly.captureException(error, {
    level: "error", // "fatal" | "error" (default) | "warning" | "info"
    title: "Checkout submit failed", // display title, also prefixes the fingerprint
    tags: { step: "payment" }, // top-level properties
    extra: { cartSize: cart.items.length },
  });
}
```

The event carries the error and its `cause` chain (outermost first, at most
5\) with the stack parsed into frames, the current breadcrumbs, the release,
and the same default properties as `track()`. `tags` and `extra` become
top-level properties (`tags` wins) but can never overwrite a reserved key
(`$exception_*`, `error_title`, `distinct_id`, `time`, `$insert_id`, `$lib`,
`token`, `$device_id`, `$user_id`). Anything that is not an `Error` (a string,
a plain object) is sent as a synthetic exception. Pass `fingerprint` to group
exceptions yourself; the server still derives the final fingerprint from it.

### Automatic capture

With `captureExceptions: true` the SDK also listens for uncaught errors and
unhandled promise rejections, both with `mechanism.handled: false`. An
uncaught error is sent with `level: "fatal"` (it counts against crash-free
users); an unhandled rejection with `level: "error"`; handlers already on the page keep running. A
rejection with a value that is not an `Error` is reported as
`UnhandledRejection`.

It also records breadcrumbs automatically:

| Category     | What                                                                                                                                   | Never                                          |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `navigation` | `"/cart -> /checkout"` on SPA route changes                                                                                            | query string, fragment                         |
| `ui.click`   | A short selector, e.g. `button#pay`                                                                                                    | element text, input values                     |
| `console`    | `console.error` arguments                                                                                                              |                                                |
| `fetch`      | `"POST /api/cart 500"` for failed or rejected requests                                                                                 | query, headers, bodies, the SDK's own requests |
| `xhr`        | `"POST /api/cart 500"` for `XMLHttpRequest`s that end with status 400 or more, a network error or a timeout (`failed`); never an abort | query, headers, bodies, the SDK's own requests |

The same error is sent at most 10 times per minute (same type, message and
crashing frame); a render loop cannot flood the queue.

An error that a global handler and `captureException()` both report within one
second (same type, message and crashing frame) is sent once. React 18 does this
in development: it re-reports a render error its boundary caught to `window`.
The event that stays is the one from the global handler (level `fatal`).

### Breadcrumbs

Add your own with `addBreadcrumb()`. The last 50 travel with every exception.
They are kept in memory only, and `reset()` clears them.

```ts
cohorly.addBreadcrumb({
  category: "checkout",
  message: "Applied coupon",
  level: "info",
  data: { coupon: "WELCOME10" },
});
```

### Scrubbing with beforeSendException

`beforeSendException` sees every exception right before it is queued. Return
the event, changed if needed, or `null` to drop it. If the hook throws, the
exception is dropped - it is never sent unscrubbed.

```ts
import { init, type ExceptionListEntry } from "@cohorly/web";

init({
  token: "YOUR_PROJECT_TOKEN",
  captureExceptions: true,
  beforeSendException(event) {
    const props = event.properties;
    if (typeof props.$current_url === "string") {
      props.$current_url = props.$current_url.split("?")[0];
    }
    const exceptions = props.$exception_list as ExceptionListEntry[];
    if (exceptions[0]?.type === "AbortError") return null; // drop
    for (const exception of exceptions) {
      exception.value = exception.value.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[email]");
    }
    return event;
  },
});
```

### Delivery

Exceptions have their own queue (50 by default, persisted), so a burst of
ordinary events can never push one out. Every flush sends exceptions first,
fatal ones before the rest, then events; when the queue is full the oldest
non-fatal exception is dropped first. When the page is hidden or closed,
fatal exceptions go out first through `sendBeacon`. Retries follow the same
rules as events.

### Releases

Set `release` to the version you deploy, from the same source as your build
(e.g. `"web@2026.11.3"` or a git tag). It is sent as `$exception_release` on
every exception and as `$app_version_string` on every event, which is what
makes crash-free users by release possible: the numerator (users with a fatal
exception in the release) and the denominator (users active in the release)
read the same string. Use `dist` for the build hash inside a release.

## Session replay

Session replay records what happened on the page (DOM, inputs masked,
clicks, scrolls, network requests) with rrweb, so you can watch a session
from the user's profile or from an error issue.

```ts
init({
  token: "<project token>",
  captureExceptions: true,
  sessionReplay: {
    sampleRate: 0.1, // record 10% of sessions whole
    onErrorSampleRate: 1, // of the rest, keep the last 30 to 60 s and send them on an error
    maskTextSelector: ".pii", // optional, added to [data-cohorly-mask]
    blockSelector: ".ads", // optional, added to [data-cohorly-block]
  },
});
```

The recorder (and rrweb) is downloaded only when the current session records
or buffers. A page without `sessionReplay`, with both rates at 0, opted out
at `init()`, or whose session was sampled out never loads it.

### Sessions

With `sessionReplay` set (and a rate above 0), every event, `$exception`
included, carries `$session_id`. A session belongs to one browser tab: a
reload continues it, and another tab, or a duplicated one, has its own. It
expires after 30 minutes without a tracked event or a recorded interaction
(the SDK's own `$metric` events do not count), or after 24 hours. Recording
stops while the session is expired, and the next interaction or tracked
event starts a new session from a full snapshot. `reset()` starts a new
session at once.

The sampling decision is made once per session. `sampleRate` picks sessions
recorded whole; among the others, `onErrorSampleRate` picks sessions that
only keep a short buffer for errors.

### Session mode

Chunks go to `POST /replay` every 5 seconds or 1000 rrweb events, gzipped
where the browser supports `CompressionStream`. When the tab is hidden or
closing, queued chunks are sent with `fetch(..., { keepalive: true })` as far
as the browser's 64 KB keepalive budget allows; the rest stays queued if the
page comes back. Chunks wait in memory only (at most 5 MB) and follow the same
retry rules as events. A 404 from `/replay` turns replay off for that page
load.

### Error mode

Error mode keeps the last 30 to 60 seconds in memory and sends them only
when the SDK captures an `$exception`. **It needs exception capture:**
`captureExceptions: true`, or your own
[`captureException()`](#error-tracking) calls. With `captureExceptions:
false` no exception is sent, so error mode never sends anything.

The recording sent with an error starts at a full snapshot, covers at least
30 seconds when the session is that old, never includes anything older than
60 seconds, and ends with the event that led to the error. Buffering then
continues, so a later error sends again (at most one send every 5 seconds).

### Quota

When the project is over its monthly replay quota, the server answers 429
`replay quota exceeded`. Session mode then stops until the `Retry-After`
time, remembered across reloads and tabs, and those sessions fall back to
the error buffer. Error-mode recordings are never refused by the quota.

### Privacy

These defaults cannot be turned off:

- Every input value is masked: every input type, `hidden` and `password`
  included, plus textarea and select.
- Text inside `[data-cohorly-mask]` (plus your `maskTextSelector`) is masked.
- `[data-cohorly-block]` elements (plus your `blockSelector`) are never
  recorded; the replay shows a placeholder of the same size.
- The page URL is recorded without its query string or fragment.
- Network requests (`fetch` and `XMLHttpRequest`, `http` and `https` only)
  are recorded as method, URL without query or fragment, status (0 for a
  network error) and duration. Never headers or bodies, never the SDK's own
  requests.

```html
<div data-cohorly-block>card form</div>
<p data-cohorly-mask>Signed in as jane@example.com</p>
```

`optOutTracking()` stops the recorder and drops everything buffered or
queued. When you proxy ingestion with `createCohorlyProxy()` from
`@cohorly/nextjs`, `/replay` is forwarded too.

## Flush and accessors

```ts
await cohorly.flush(); // send the queued batch now
const id = cohorly.getDistinctId();
const anon = cohorly.isAnonymous();
```

The queue is persisted to localStorage, so events survive reloads and are
retried on the next flush if a request fails.

## Opt-out

```ts
cohorly.optOutTracking(); // persisted in localStorage
cohorly.hasOptedOutTracking(); // true
cohorly.optInTracking();
```

Opting out drops everything queued and clears breadcrumbs. While opted out
nothing is queued or sent (events, people updates, `/alias` and `$exception`)
and no feature flag request is made. Opting back in does not restore what was
dropped. To turn off only error reports, init with `captureExceptions: false`.

## Keeping the token out of the bundle

Passing `token` to `init()` embeds it in your client JS. If you would rather
not, proxy ingestion server-side with `@cohorly/nextjs`'s
`createCohorlyProxy()` and omit `token` here. See the
[Next.js SDK](/sdks/nextjs).
