# JavaScript SDK

Tailglow's JavaScript SDK reports **events**, **errors**, and **logs** to your project from anywhere JavaScript runs: a browser app, a Node or Bun backend, an edge function, Electron, or React Native. A shared core handles queueing, batching, retry attempts, redaction, and sampling. A thin per-runtime package adds the auto-collection and lifecycle wiring that platform needs.

The mental model is small: you `track()` events and `identify()` users, and the SDK routes every record into one of a few **collections** (`events`, `errors`, `logs`), stamping each with a `type` that says what it is. Batching, retries, redaction, and delivery attempts are handled for you on every runtime; browser and React Native apps also collect page views, errors, and more automatically.

New here? The **[Quickstart](/guides/quickstart)** gets a web app reporting in about two minutes. This page is the full reference.

## Runtimes

| Runtime                              | Package                                                       | Automatic out of the box                                                                                                                                                                     |
| ------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Browser** (web apps, static sites) | `@tailglow/browser`                                           | Page views, tagged-section activity, declarative and outbound clicks, uncaught errors, performance signals, device snapshot, plus best-effort exit flush via `sendBeacon`                    |
| **Backend** (Node, Bun, edge)        | `@tailglow/core`                                              | Nothing automatic: you `track()` explicitly and flush before the process or request ends                                                                                                     |
| **Electron**                         | renderer uses `@tailglow/browser`, main uses `@tailglow/core` | Renderer behaves like a browser after allowing `file://`; main behaves like a backend                                                                                                        |
| **React Native / Expo**              | `@tailglow/react-native`                                      | JS errors and supported rejection hooks, console, and device once modules are injected; screen views after `attachNavigation()`; best-effort persistence and flush with AppState and storage |

Every package exposes the same core API: `track`, `captureException`, `identify`, `setContext`, `flush`, and friends. What changes per runtime is which events are collected for you and how exit or shutdown flushing is wired. ESM only, no CJS bundle. Runtime floors: Node `>=22`, Bun any, React Native `>=0.74`, evergreen browsers (Chrome and Firefox 90+, Safari 14+, Edge 90+), and recent edge runtimes.

## Installation

```bash
bun add @tailglow/browser
# or
npm install @tailglow/browser
```

## Drop-in `<script>` (no build step)

For static sites and snippet-paste integrations, the IIFE bundle self-initializes from `data-*` attributes on its own `<script>` tag and registers `window.tglow` as a function. Drop the loader into your page:

```html
<script
  defer
  src="https://cdn.tailglow.io/tglow.js"
  data-key="tg_ingest_your_key"
  data-url="https://{region}.ingest.tailglow.io/{project_id}"
></script>
```

Optionally, add a pre-load stub before the loader so calls made before the SDK finishes loading are queued and replayed on init:

```html
<script>
  // Optional pre-load stub. Calls made before the SDK loads are replayed on init.
  window.tglow =
    window.tglow ||
    function () {
      (window.tglow.q = window.tglow.q || []).push(arguments);
    };

  tglow("track", "signup", { plan: "pro" });
  tglow("identify", "user_123");
</script>
```

Override the default collection routing via additional data-attrs (rare; defaults are `events` / `errors` / `logs`):

```html
<script
  defer
  src="https://cdn.tailglow.io/tglow.js"
  data-key="tg_ingest_..."
  data-url="https://{region}.ingest.tailglow.io/{project_id}"
  data-collection-events="myapp_events"
  data-collection-errors="myapp_errors"
  data-collection-logs="myapp_logs"
></script>
```

Set all three to the same slug to merge everything into one timeline collection.

### Declarative event tracking

`data-tglow-event="<name>"` fires `tg.track("<name>", props)` on click and routes to the configured `events` collection with `type: "<name>"`. Other `data-tglow-*` attributes become props on the record.

```html
<button data-tglow-event="signup" data-tglow-method="github">Sign up with GitHub</button>
<a href="/pricing" data-tglow-event="pricing_click" data-tglow-source="hero">See pricing</a>
```

## Initializing

The full browser walkthrough is in the [Quickstart](/guides/quickstart). Below is the minimal init per runtime.

### Browser

```javascript
import { Tailglow } from "@tailglow/browser";

const tg = new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key"
});

// Auto-collection starts immediately: page views, section visibility,
// errors, performance signals, and device info.

tg.track("signup", { plan: "pro" });
tg.identify("user_123");
```

### Node / Bun (via `@tailglow/core`)

```javascript
import { TailglowCore } from "@tailglow/core";

const tg = new TailglowCore({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key"
});

tg.track("job_completed", { job_id: "abc" });

process.on("SIGTERM", async () => {
  await tg.flush();
  await tg.destroy();
});
```

### React Native (`@tailglow/react-native`)

```javascript
// App.tsx
import AsyncStorage from "@react-native-async-storage/async-storage";
import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
import { createAsyncStorageAdapter, Tailglow } from "@tailglow/react-native";
import { useEffect } from "react";
import { AppState, Dimensions, NativeModules, Platform } from "react-native";

const tg = new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  appState: AppState,
  platform: Platform,
  dimensions: Dimensions.get("window"),
  nativeConstants: NativeModules.PlatformConstants,
  storageAdapter: createAsyncStorageAdapter(AsyncStorage)
});

export default function App() {
  const navigationRef = useNavigationContainerRef();
  useEffect(() => {
    tg.attachNavigation(navigationRef); // → automatic page_view on every screen change
  }, []);
  return <NavigationContainer ref={navigationRef}>{/* screens */}</NavigationContainer>;
}
```

With the modules wired above, JS errors, console activity, a one-time `device` record, and persist-and-flush on `AppState` background are automatic. Unhandled rejection capture is best-effort because it depends on the rejection hooks exposed by the current Hermes, JSC, and bundler combination. The AsyncStorage adapter restores up to the newest 1,000 persisted records after a cold start, but duplicate delivery is possible if a record was sent before its persisted snapshot was cleared. The one wire-up the SDK cannot infer is your navigation library, hence `attachNavigation(navigationRef)`: React Native has no universal navigation primitive. Expo Router uses React Navigation underneath, so the same call covers both (pass the ref from `useNavigationContainerRef()`).

This package captures JS-layer behavior only. Native crashes (iOS Swift, Android Kotlin) are out of scope, and even fatal JS crashes are best-effort since the runtime may die before the AsyncStorage write finishes. Screen-view records land in the `events` collection as `type: "page_view"` like the browser, but with `from_screen` / `to_screen` / `params` instead of `from_path` / `to_path` / `url`; query both platforms together and expect both field sets.

## Identity model

Browser identity is **ephemeral by default**. The SDK stores no `device_id`, `user_id`, or session identifier in cookies or browser storage. A `user_id` is stable across loads only when your app calls `identify()` again from its own authenticated session.

| Layer        | Source                                                                                             |
| ------------ | -------------------------------------------------------------------------------------------------- |
| `session_id` | In-memory, regenerated after `sessionTimeout` of inactivity                                        |
| `user_id`    | `identify(user_id)`. Same-session records can be backfilled until their request body is serialized |
| `device_id`  | `setDeviceId(id)`. Opt-in, customer-supplied (mobile/desktop)                                      |

Sticky sampling resolves in cascade: `user_id` → `device_id` → `session_id`. Without an identifier above session, sampling resets per session (the privacy-preserving default).

## Public API

### Collections model

The SDK manages three collections (configurable; defaults shown):

| Collection | What lands here                                                                                                 |
| ---------- | --------------------------------------------------------------------------------------------------------------- |
| `events`   | Every `track()` call and every auto-collected event (page views, clicks, vitals, device snapshots, navigation). |
| `errors`   | `captureException`, `captureMessage`, and `console.error`.                                                      |
| `logs`     | Non-error console levels selected by `autoConsole`. By default only `console.warn` emits here.                  |

Records carry a `type` field that discriminates within the collection (page_view, click, vital, signup, purchase, or whatever you pass to `track()`). The collection is the routing destination; the `type` is the event kind.

```javascript
tg.track("signup", { plan: "pro" });
// → POST ?collection=events with { type: "signup", plan: "pro", session_id: ... }

tg.track("purchase", { amount: 99 });
// → POST ?collection=events with { type: "purchase", amount: 99, session_id: ... }
```

Override per call when you need a separate collection (rare, but useful for separating high-volume telemetry or audit records from the default event schema). The override uses the **object form** of `track()`:

```javascript
tg.track({ type: "audit_event", collection: "audit_log", actor: "user_42" });
// → POST ?collection=audit_log with { type: "audit_event", actor: "user_42", ... }
```

The string form `track(name, props)` is the 99% case; the object form is reserved for the per-call routing override. There's no third positional argument; pass everything (type, collection, props) in one object.

Configure the destinations at construction time:

```javascript
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  collections: {
    events: "events", // default; override to namespace per app on a shared source
    errors: "errors",
    logs: "logs"
  }
});
```

Set all three to the same slug to merge everything into one timeline collection:

```javascript
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  collections: { events: "timeline", errors: "timeline", logs: "timeline" }
});
```

### Sending events

`track(name, props?)` (string form)
The `name` parameter is the **event type**, stamped as the `type` field on the record. Routes to the configured `collections.events` destination (default: `events`).

`track({ type, collection?, ...props })` (object form)
Use this when you need to route a single record to a different collection. `type` is required; `collection` overrides the default routing; everything else becomes record props.

```javascript
tg.track("signup", { plan: "pro" });
tg.track("purchase", { amount: 99, currency: "USD" });
```

Customer-supplied `props.type` wins on collision: pass `type: "..."` explicitly in props to override the SDK's stamp.

### Errors and exceptions

`captureException(err, props?)`
Capture an exception. Routes to the configured `collections.errors` destination (default: `errors`). The SDK parses the stack into structured frames, computes a stable fingerprint for the client burst limiter and your own filtering, snapshots the breadcrumb buffer, and stamps mechanism metadata. The record's top-level `type` field is the JS error class name (e.g. `"TypeError"`); within errors, `type` discriminates error kinds the same way it discriminates event kinds within events.

```javascript
try {
  riskyOperation();
} catch (err) {
  tg.captureException(err, { user_step: "checkout" });
}
```

`level` defaults to `"error"`. Override via props:

```javascript
tg.captureException(err, { level: "fatal", user_step: "checkout" });
```

`fingerprint` is a stable string included in the record and used by the client burst limiter. The SDK computes it from the frame signature; pass an explicit string in props to control which errors share a burst budget or to provide your own downstream grouping key:

```javascript
tg.captureException(err, { fingerprint: "payment-flow" });
```

`captureMessage(message, props?)`
Capture a message-style event (no Error to attach). Same destination as `captureException` (the configured `errors` collection); use this for assertion failures, recoverable warnings, or business invariants. Default `level` is `"info"`. The record's `type` is `"Message"`.

```javascript
tg.captureMessage("payment validator returned null", { level: "warning" });
```

`addBreadcrumb(entry)`
Push a breadcrumb manually. Auto-collectors do this for you (console activity, page and screen navigations, section and outbound clicks), but you can leave your own context markers.

```javascript
tg.addBreadcrumb({
  category: "navigation",
  message: "/home → /checkout",
  timestamp: Date.now()
});
```

#### Auto-captured errors and rejections

When `autoErrors` is enabled (default), the browser SDK installs `window.addEventListener("error")` and `unhandledrejection` listeners that route through `captureException` automatically. Same record shape as a manual call; `mechanism.type` differs (`"uncaught"` / `"unhandled_rejection"` vs. `"manual"`).

#### Auto-captured console activity

When `autoConsole` uses its default value of `["error", "warn"]`, all supported `console.*` calls are wrapped for breadcrumbs, but only warnings and errors emit records:

| Console method              | Default effect                                   | Record destination  | `type`           |
| --------------------------- | ------------------------------------------------ | ------------------- | ---------------- |
| `console.log`               | breadcrumb only                                  | none                | n/a              |
| `console.warn`              | record + breadcrumb                              | `logs` collection   | `"warn"`         |
| `console.info`              | breadcrumb only                                  | none                | n/a              |
| `console.debug`             | breadcrumb only                                  | none                | n/a              |
| `console.error(err: Error)` | `captureException` + breadcrumb                  | `errors` collection | error class name |
| `console.error(string)`     | `captureMessage` (level: `"error"`) + breadcrumb | `errors` collection | `"Message"`      |

The wrapper preserves the original console output: DevTools still shows everything as before. The only loss is DevTools' "source" column shows the wrapper's file/line instead of the actual caller. This is the standard tradeoff every error tracker makes.

When a non-error level is included in `autoConsole`, its record follows these argument-shape rules:

```javascript
console.log("user clicked save");
// → { type: "log", message: "user clicked save" }

console.log({ user_id: 42, action: "save" });
// → { type: "log", user_id: 42, action: "save", message: "{...}" }   (object flattened)

console.log("user clicked", { id: 42, name: "Alice" });
// → { type: "log", message: "user clicked {...}", id: 42, name: "Alice" }
```

#### Burst protection

To catch runaway loops (the same error firing thousands of times per second), the SDK rate-limits per fingerprint. After 10 events of the same fingerprint within 1 second, the next event triggers a 2-minute cooldown and is dropped. The first event emitted after cooldown carries `burst_suppressed: N` only when additional events were dropped during that cooldown. `N` counts those additional cooldown drops and does not include the event that triggered cooldown.

Configurable via:

```javascript
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  errorBurst: { threshold: 10, window_ms: 1000, cooldown_ms: 120_000 }
});
```

The fingerprint counter map is LRU-evicted at 1000 entries to bound memory in long-running sessions.

Underneath the per-fingerprint limiter is a global token-bucket limiter that caps the total record rate across every type (`track()`, auto-collectors, errors, logs). It protects the client from a runaway emit loop, but it does not guarantee that the ingest server will accept every request. Invalid credentials, malformed or oversized payloads, unavailable capacity, and other server failures can still reject a request. The limiter runs after sticky sampling and before stamping, redaction, size checks, `onBeforeSend`, and queueing. The bucket holds 200 tokens and refills 120 per minute by default; every admitted record spends one token, and when the bucket empties records are dropped. Drops surface as a collapsed `tglow_rate_limited` self-event carrying `limited_count` (records dropped since the last notice) and `window_ms`, emitted at most once per 60 seconds. Tune it with the `rateLimit` option (`burst` must be a finite number at least 1, `perMinute` a finite number at least 0, where `perMinute: 0` means drain-only with no refill), or pass `rateLimit: false` to disable it. Any out-of-range value (including `burst: 0`) falls back to that field's default and logs one `[tglow]` warning, so a bad config can never silently disable the brake or drop everything.

#### `errors` collection record shape

```json
{
  "type": "TypeError",
  "message": "Cannot read 'x' of null",
  "stack_raw": "TypeError: Cannot...",
  "frames": [
    {
      "filename": "https://cdn.example.com/app.min.js",
      "function": "calculateTotal",
      "lineno": 1,
      "colno": 48201,
      "in_app": true
    }
  ],
  "fingerprint": "9c3f8a1b",
  "mechanism": { "type": "uncaught", "handled": false },
  "level": "error",
  "cause": { "type": "Error", "message": "...", "frames": [] },
  "breadcrumbs": [
    { "category": "console", "level": "log", "message": "...", "timestamp": 1700000000000 }
  ],
  "sdk": { "name": "tailglow.browser", "version": "x.y.z" }
}
```

#### `logs` collection record shape

```json
{
  "type": "log",
  "message": "user clicked save",
  "session_id": "sess_...",
  "event_id": "...",
  "event_time": "...",
  "sdk": { "name": "tailglow.browser", "version": "x.y.z" }
}
```

### Identity & context

`identify(user_id)`: set the user ID. Pre-identify records from the same session are backfilled while they remain buffered or in an in-flight SDK batch that has not yet been serialized. A request body already serialized for `fetch` cannot be changed and remains anonymous.

`unidentify()`: clear the current user ID. Equivalent to `identify("")` but explicit.

`setDeviceId(id)`: set a customer-supplied device ID for subsequent records. The SDK does not persist it; mobile or desktop apps are responsible for supplying the same stable identifier again on a later launch. Browsers should normally leave this unset.

`rotateSession()`: force a new session ID at an explicit boundary (workflow finished, end-of-flow). Returns the new ID.

`setContext(ctx)`: merge sticky context. Every key here is stamped on every subsequent record (only if the record doesn't already have that key). Values must be JSON-serializable (functions and `undefined` are dropped). The context bag is capped at 64 top-level keys and 8192 serialized bytes. A non-object value or array is ignored without a warning. An object patch that cannot be serialized, resolves to a non-object through `toJSON`, or would exceed either cap is rejected as a whole, leaving the existing bag unchanged, and the SDK logs one `[tglow]` warning for that violation kind. Common conventions are `release`, `environment`, `dist`, `region`, `plan`, but the SDK doesn't reserve any of them; they're just sticky fields.

`clearContext()`: drop everything previously added via `setContext`.

```javascript
tg.identify("usr_123");
tg.setContext({
  release: "v2.1.0",
  environment: "production",
  email: "alice@example.com",
  plan: "pro",
  region: "us-west",
  subscription: { tier: "pro", seats: 12 }
});
```

Tailglow doesn't impose a specific shape on your context: flat or nested, whatever queries naturally for your data model. Mid-session updates (e.g. CodePush bumps `release`, an Electron auto-update flips `dist`, a UI toggle changes `environment`) are just `setContext({...})` calls. There are no special-case setters.

#### Sticky-sample identity transition

Sampling key cascades `user_id` → `device_id` → `session_id`. An anonymous user (sampled by `session_id`) who later calls `identify()` shifts to `user_id`-keyed sampling, and the verdict can flip in or out mid-session. Identify before any tracking when you need the sampling verdict to stay keyed to the user for the whole session.

#### Identity changes (logout / login / org switch)

There is no `reset()` method. The pattern is flush, destroy, reconstruct:

```javascript
await tg.flush(); // attempt to drain pending records under the old identity
await tg.destroy(); // remove listeners, stop the queue interval
tg = new Tailglow({ ...sdkConfig });
```

### Opt-out and privacy

The default browser SDK path is cookieless and uses no persistent browser storage. Whether you need consent or a banner still depends on what you collect, how you use it, and the laws that apply to you. Disclose collection in your privacy policy and honor Do Not Track / Global Privacy Control (the browser SDK does by default, configurable).

To let a user turn analytics off:

`optOut()`: clear buffered records and make the core drop new records until `optIn()`. It does not cancel a request body already handed to `fetch`, and browser collectors remain installed. Some collectors can retain observations while opted out and emit them after a later `optIn()`. Call `await tg.flush()` first only if you intentionally want to attempt delivery of buffered records before opting out.
`optIn()` / `isOptedOut()`: resume tracking / check status.

```javascript
tg.optOut(); // drop buffered records and stop accepting new records in the core
tg.optIn(); // resume
```

For consent gating, construct the SDK only after the user accepts. On revocation, call `optOut()` first to clear buffered records, then call `destroy()` to remove collectors and listeners. Neither call can cancel a request body already handed to `fetch`. Create a new SDK instance if the user later grants consent again.

```javascript
if (userAccepted) tg = new Tailglow({ ...config });
// later, to revoke:
tg.optOut();
await tg.destroy();
```

### Lifecycle

`flush()`: async; attempt to flush all queued records and wait for the current flush cycle. Retryable failures that exhaust their transport attempts remain queued.
`destroy()`: async; attempt one final queue flush, remove platform listeners, and stop the interval. It does not guarantee delivery after a permanent or exhausted transient failure.
`getStats()`: `{queueCount, queueBytes, sentCount, lastSendAt, lastSendOk}` for production debugging. Queue count and bytes cover the buffered queue, not records currently in an in-flight batch.

#### Exit-time delivery

Browser tab-hide delivery via `navigator.sendBeacon` is best-effort. `@tailglow/core` does not install Node process listeners, so wire your own shutdown handler and await `flush()` and `destroy()` while the runtime can still perform network work. Those calls wait for the SDK's delivery attempts, but they cannot guarantee delivery when the network or ingest service keeps failing.

## Configuration

All options have sensible defaults. Only `url` is required, and it may already carry the key.

### Core options (all packages)

| Option                     | Type                              | Default                                                | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| -------------------------- | --------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`                      | `string`                          | required                                               | Full project ingest endpoint, such as `https://{region}.ingest.tailglow.io/{project_id}`. Copy it from the source's Ingest tab, where it already carries `?key=`.                                                                                                                                                                                                                                                                                                                                           |
| `key`                      | `string`                          | optional                                               | Ingest key (starts with `tg_ingest_`). Omit it when `url` carries one; supplying it overrides the carried key.                                                                                                                                                                                                                                                                                                                                                                                              |
| `enabled`                  | `boolean`                         | `true`                                                 | Environment gate. When `false`, normal capture, identity, context, lifecycle, and diagnostic methods are inert or return neutral values. No timers, network, queue, or collectors are created. With `debug: true`, construction logs one disabled diagnostic. Do not call core-only transport or queue escape hatches such as `getTransport()` or `drainQueue()` on a disabled `TailglowCore`.                                                                                                              |
| `collections`              | `{ events?, errors?, logs? }`     | `{ events: "events", errors: "errors", logs: "logs" }` | Routing destinations. `events` = `track()` + event auto-collectors; `errors` = `captureException`/`captureMessage`/uncaught errors/`console.error`; `logs` = the other enabled console wrappers. Set all three to the same slug to merge into one timeline collection.                                                                                                                                                                                                                                      |
| `context`                  | `Record<string, unknown>`         | -                                                      | Initial sticky context (JSON-serializable values only). Every key is stamped on every record unless the record already has that key. Mergeable later via `setContext()`. Capped at 64 top-level keys and 8192 serialized bytes. A non-object value or array is ignored silently; an object that cannot serialize to an in-cap JSON object is rejected whole and logs one warning for that violation kind.                                                                                                   |
| `flushInterval`            | `number`                          | `30000`                                                | Auto-flush interval in ms                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `flushSize`                | `number`                          | `100`                                                  | Auto-flush at this record count                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `maxBatchBytes`            | `number`                          | `15000000`                                             | Approximate serialized-byte target used to split queued records into outgoing batches. A single record can exceed it when `maxRecordBytes` is configured higher.                                                                                                                                                                                                                                                                                                                                            |
| `maxQueueSize`             | `number`                          | `10000`                                                | Maximum records in the buffered queue. In-flight batches are tracked separately, so this is not a strict process-wide record or memory cap. The oldest buffered record is dropped when the buffer is full.                                                                                                                                                                                                                                                                                                  |
| `maxRecordBytes`           | `number`                          | `1000000` (1MB)                                        | Drop records larger than this (serialized JSON). Emits a collapsed `tglow_record_dropped` self-event, at most one per drop reason (size or unserializable) per 60s. `dropped_count` spans every collection dropped for that reason in the window, and the other `dropped_*` fields reflect the most recent drop. Pass `Infinity` to disable.                                                                                                                                                                |
| `sessionTimeout`           | `number`                          | `1800000`                                              | Session timeout (30 min)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `onSessionRotate`          | `(info) => void`                  | -                                                      | Fires after the session ID rotates (timeout or `rotateSession()`), with a snapshot of the ended session: `previous_session_id`, `reason`, `started_at`, `last_activity_at`.                                                                                                                                                                                                                                                                                                                                 |
| `maxRetries`               | `number`                          | `3`                                                    | Retry attempts after network errors and 5xx, 408, or 429 responses. Honors `Retry-After`.                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `retryDelay`               | `number`                          | `1000`                                                 | Initial retry delay (doubles each attempt)                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `sampleRate`               | `number`                          | `1.0`                                                  | Sticky sampling rate (0 to 1)                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `deviceId`                 | `string`                          | -                                                      | Customer-supplied device ID for this SDK instance. The caller owns persistence across launches.                                                                                                                                                                                                                                                                                                                                                                                                             |
| `userId`                   | `string`                          | -                                                      | Initial user ID                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `storageAdapter`           | `StorageAdapter`                  | -                                                      | Async storage adapter for non-browser persistence                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `onBeforeSend`             | `(record) => record \| null`      | -                                                      | Filter/enrich records, return null to drop                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `onTransportError`         | `(error, batch) => void`          | -                                                      | Fires for a collection group rejected by a non-retryable 4xx and for unexpected exceptions in the SDK send path, with the affected records. Exhausted retryable network, 408, 429, and 5xx failures are requeued without calling this hook.                                                                                                                                                                                                                                                                 |
| `redact.enabled`           | `boolean`                         | `true`                                                 | Master redaction switch                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `redact.redactEmails`      | `boolean`                         | `true`                                                 | Redact email-shaped strings                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `redact.redactFields`      | `string[]`                        | -                                                      | Drop these top-level fields                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `redact.allowFields`       | `string[]`                        | -                                                      | Restrict top-level fields to an allowlist. Internal `__tg_*` routing plus `event_id`, `event_time`, `session_id`, `user_id`, `device_id`, and `sdk` pass automatically. Include `type` in the allowlist if you want to preserve it.                                                                                                                                                                                                                                                                         |
| `redact.stripQueryStrings` | `boolean`                         | `false`                                                | Strip the query string and hash from the top-level `url`, `referrer`, and `href` fields (rewrites to origin plus pathname). Only `http` and `https` URLs are rewritten; other schemes (`ftp:`, `data:`, `blob:`, etc.) and unparseable values pass through unchanged. Opt in. Leaves nested values and breadcrumbs untouched.                                                                                                                                                                               |
| `errorBurst`               | `object`                          | see above                                              | Per-fingerprint client-side rate limit. Keys: `threshold` (default 10), `window_ms` (1000), `cooldown_ms` (120000), `max_entries` (1000).                                                                                                                                                                                                                                                                                                                                                                   |
| `rateLimit`                | `{ burst?, perMinute? } \| false` | `{ burst: 200, perMinute: 120 }`                       | Global token-bucket rate limit across every record type, enforced after sticky sampling and before stamping or queueing. Each admitted record spends one token; when the bucket empties records are dropped and a collapsed `tglow_rate_limited` self-event reports the count (at most once per 60s). `burst` must be a finite number at least 1 and `perMinute` a finite number at least 0 (`perMinute: 0` is drain-only); invalid values fall back to defaults with one warning. Pass `false` to disable. |
| `breadcrumbBuffer`         | `number`                          | `100`                                                  | Ring buffer size for breadcrumbs.                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `debug`                    | `boolean`                         | `false`                                                | Console logging                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |

**Environment gating.** Set `enabled: false` for environments where you don't want to report, such as unprovisioned previews, CI, or local development. A disabled instance starts no timers, opens no network connections, queues nothing, and installs no auto-collectors. Normal public wrapper methods remain safe and diagnostic getters return neutral values. `debug: true` deliberately logs one disabled diagnostic, while the default `debug: false` path stays silent even when `url` and `key` are empty. The recommended pattern is `enabled: env === "production" && Boolean(key)`.

### Browser-only options (`@tailglow/browser`)

| Option                  | Type                                             | Default            | Description                                                                                                                                                                                                                                      |
| ----------------------- | ------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `autoPageViews`         | `boolean`                                        | `true`             | SPA navigation tracking                                                                                                                                                                                                                          |
| `autoSections`          | `boolean`                                        | `true`             | `data-telemetry` element visibility + clicks                                                                                                                                                                                                     |
| `autoErrors`            | `boolean`                                        | `true`             | Uncaught errors and unhandled rejections                                                                                                                                                                                                         |
| `autoVitals`            | `boolean`                                        | `true`             | Lightweight browser performance signals named LCP, CLS, INP, FCP, and TTFB. These are SDK approximations, not standards-compliant Core Web Vitals calculations.                                                                                  |
| `autoDevice`            | `boolean`                                        | `true`             | Send device info on init                                                                                                                                                                                                                         |
| `autoSessionSummary`    | `boolean`                                        | `true`             | Emit `session_summary` records (engaged time, pages viewed, entry/exit paths) on tab hide/unload and session rotation. Requires `autoPageViews`. Each emission carries cumulative totals for its `session_id`: read the last record per session. |
| `autoConsole`           | `Array<"log"\|"warn"\|"info"\|"debug"\|"error">` | `["error","warn"]` | Console levels that emit records on the wire. Levels not listed still feed breadcrumbs. `[]` disables wrapping entirely.                                                                                                                         |
| `spaMode`               | `"auto" \| "history" \| "hash" \| "off"`         | `"auto"`           | SPA navigation strategy                                                                                                                                                                                                                          |
| `routeContext`          | `() => object \| null`                           | -                  | Called at every page_view emission; returned fields (route template, params) merge into the record. Auto-collected fields win on collision. Use it to stamp a stable router route ID for downstream views and metrics.                           |
| `sectionAttribute`      | `string`                                         | `"data-telemetry"` | Attribute name for section tracking                                                                                                                                                                                                              |
| `intersectionThreshold` | `number`                                         | `0.5`              | Visibility ratio for "seen"                                                                                                                                                                                                                      |
| `honorDnt`              | `boolean`                                        | `true`             | Honor `navigator.doNotTrack`                                                                                                                                                                                                                     |
| `honorGpc`              | `boolean`                                        | `true`             | Honor `navigator.globalPrivacyControl`                                                                                                                                                                                                           |
| `excludeLocalhost`      | `boolean`                                        | `true`             | Skip tracking on localhost / file://                                                                                                                                                                                                             |
| `respectDevOptOut`      | `boolean`                                        | `false`            | Read `tglow_ignore` localStorage flag                                                                                                                                                                                                            |

## Privacy defaults

The browser SDK is designed to operate without persistent client storage:

| Default            | Behavior                                                                                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Honor DNT          | If `navigator.doNotTrack === "1"`, the SDK is fully disabled.                                                                                                                   |
| Honor GPC          | If `navigator.globalPrivacyControl === true`, the SDK is fully disabled.                                                                                                        |
| Skip localhost     | The SDK does not track on `localhost`, `127.0.0.1`, `0.0.0.0`, or `file://`.                                                                                                    |
| No browser storage | The default browser path does **not** read or write cookies, localStorage, sessionStorage, or IndexedDB.                                                                        |
| Redaction          | URL token patterns and email-shaped strings are redacted before send, recursively through at most eight container levels. Strings longer than 4,096 characters are not scanned. |

The `tglow_ignore` developer escape hatch (set `localStorage.setItem("tglow_ignore", "true")` in your browser) is opt-in via `respectDevOptOut: true`. Useful for staging/dev builds.

### Console capture

Default `autoConsole: ["error", "warn"]`: only severity-flagged console calls become records on the wire. `log` / `info` / `debug` are still wrapped (so they feed the breadcrumb buffer that attaches to the next captured error), but don't emit records by themselves. This matches Sentry-style "errors with context" behavior out of the box, while leaving the data-lake firehose one config away.

To broaden capture, list more levels. To disable entirely, pass `[]`.

```js
// Default: errors and warnings as records, all levels in breadcrumbs
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key"
});

// Errors only on the wire, breadcrumbs still capture from log/warn/info/debug
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  autoConsole: ["error"]
});

// Capture everything (data-lake mode)
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  autoConsole: ["log", "warn", "info", "debug", "error"]
});

// Disable entirely: no wrapping, no breadcrumbs from console
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  autoConsole: []
});
```

Routing per level when emitted:

- `log` / `warn` / `info` / `debug` → configured `logs` collection with `type: "<level>"`
- `error(Error)` → `captureException` → configured `errors` collection with `type` = error class name
- `error(string)` → `captureMessage` (level: `"error"`) → configured `errors` collection with `type: "Message"`

**PII**: whatever your code logs can be sent. Automatic URL-token and email redaction scans strings up to 4,096 characters and traverses at most eight container levels, including typical breadcrumb messages, frame filenames, and nested objects within those limits. `redact.redactFields` and `redact.allowFields` apply only to top-level fields. For stricter guarantees, remove sensitive data at the source or in `onBeforeSend` instead of relying only on automatic redaction.

**Opt-out**: breadcrumbs are dropped while the SDK is opted out (`tg.optOut()`). Pre-opt-out activity does not leak into post-opt-in captures.

## Testing on localhost

By default the SDK skips tracking on `localhost`, `127.0.0.1`, `0.0.0.0`, and `file://` so your local dev runs don't pollute production analytics. To verify the integration end-to-end during development, opt back in with `excludeLocalhost: false`.

### ESM

```javascript
import { Tailglow } from "@tailglow/browser";

const tg = new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_your_key",
  excludeLocalhost: false, // enable on localhost
  context: { environment: "development" }, // tag dev events so they're filterable
  debug: true // log SDK activity to the console
});
```

### Drop-in script

```html
<script
  defer
  src="https://cdn.tailglow.io/tglow.js"
  data-key="tg_ingest_your_key"
  data-url="https://{region}.ingest.tailglow.io/{project_id}"
  data-exclude-localhost="false"
  data-environment="development"
  data-debug="true"
></script>
```

(`data-release`, `data-environment`, and `data-dist` flow into `context` automatically. For richer context, call `tglow("setContext", {...})` after the script loads.)

### Recommended: tag dev events with a distinct `environment`

When you enable localhost tracking, the events flow into the same ingest as production. Set `context: { environment: "development" }` (or `"staging"`, `"local"`, whatever you want) so you can filter dev events out of production dashboards or build a separate "Dev events" view. Without this, your local records show up next to real customer events and skew metrics.

A common pattern is to drive it from the build environment. This example uses Vite's `import.meta.env`; adapt the environment API for your bundler:

```javascript
const environment = import.meta.env.MODE;

new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_...",
  excludeLocalhost: import.meta.env.PROD,
  context: { environment }
});
```

### Want to disable in dev too? Use `tglow_ignore`

The `tglow_ignore` localStorage flag is a per-browser kill-switch. Useful when you're running an env where the SDK is enabled but you specifically don't want your own events captured (QA accounts, automated tests, your own dev session). The SDK only reads it when configured with `respectDevOptOut: true`:

```javascript
new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_...",
  respectDevOptOut: true
});
```

Then in the browser console: `localStorage.setItem("tglow_ignore", "true")`. SDK will be disabled on next load.

## Auto-collected events (browser)

Browser event auto-collectors route page views, clicks, vitals, device snapshots, and similar records to the configured `events` collection with a `type` discriminator. Uncaught errors route to `errors`; enabled console wrappers route warnings and other non-error console records to `logs`, while `console.error` routes to `errors`. Use the `type` field when building views, metrics, or facet-filtered records API requests to distinguish `page_view`, `click`, `vital`, and other record kinds.

### Page views: `type = "page_view"`

```json
{
  "type": "page_view",
  "from_path": "/",
  "to_path": "/pricing",
  "duration_ms": 4500,
  "nav_type": "push",
  "url": "https://example.com/pricing"
}
```

The URL field uses `<link rel="canonical">` if present, otherwise `window.location.href`.

### Section visibility & clicks

`type = "section_view"` | `"click"` | `"outbound_click"` | `"scroll_depth"`.

```html
<section data-telemetry="hero">...</section>
```

```json
// section_view
{ "type": "section_view", "section": "hero", "path": "/", "duration_ms": 8200 }
// click
{ "type": "click", "section": "pricing", "path": "/", "tag": "button", "text": "Sign up" }
// outbound_click
{ "type": "outbound_click", "href": "https://github.com/...", "text": "View on GitHub", "path": "/" }
// scroll_depth
{ "type": "scroll_depth", "depth_percent": 85, "path": "/" }
```

### Errors: `errors` collection

```json
{
  "type": "TypeError",
  "message": "Cannot read 'x' of null",
  "stack_raw": "TypeError: Cannot read 'x' of null\n    at ...",
  "frames": [
    {
      "filename": "https://cdn.example.com/app.min.js",
      "function": "calculateTotal",
      "lineno": 1,
      "colno": 48201,
      "in_app": true
    }
  ],
  "fingerprint": "9c3f8a1b",
  "mechanism": { "type": "uncaught", "handled": false },
  "level": "error",
  "breadcrumbs": [
    { "category": "console", "level": "log", "message": "...", "timestamp": 1700000000000 }
  ]
}
```

`type` here is the JS error class name. Same shape from manual capture (`captureException`), the auto-collector (`window.addEventListener("error", ...)` / `unhandledrejection`), and the console wrapper (`console.error(err)`); only `mechanism.type` differs (`"manual"` / `"uncaught"` / `"unhandled_rejection"` / `"console"`).

### Performance signals: `type = "vital"`

```json
{ "type": "vital", "vital_name": "LCP", "vital_value": 1200, "path": "/" }
{ "type": "vital", "vital_name": "CLS", "vital_value": 0.05, "path": "/" }
```

These are lightweight performance measurements, not standards-compliant Core Web Vitals. LCP uses the latest observed largest-contentful-paint start time. CLS sums layout-shift values without recent input across the page lifetime, rather than applying the standard session-window algorithm. INP is the maximum observed event duration, rather than the standard interaction percentile calculation. FCP and TTFB come directly from paint and navigation performance entries.

### Device: `type = "device"`

Sent once on init. Includes browser, OS, device type, screen, viewport, language, timezone, connection (effectiveType / downlink / rtt), referrer source, and UTM parameters.

### Session summary: `type = "session_summary"`

```json
{
  "type": "session_summary",
  "session_id": "sess_Abc123",
  "engaged_ms": 145200,
  "pages_viewed": 7,
  "entry_path": "/",
  "exit_path": "/pricing",
  "reason": "hide"
}
```

Emitted on tab hide/unload (`reason: "hide"` / `"unload"`), on session rotation (`"rotate"`), and on `destroy()` (`"destroy"`). Each emission carries **cumulative** totals for its `session_id`: totals only grow within a session, so read the last record per session (for example, a metric grouped by `session_id` with the last-value chart mode). `engaged_ms` counts only active, visible time, matching the page_view `duration_ms` semantics. Disable with `autoSessionSummary: false`.

## Record metadata

The SDK stamps this metadata when it is available, but **only if the customer hasn't already set the field**. Customer-explicit values always win on collision.

| Field        | Description                                                                                                                                                                                                                                                               |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_id`   | Unique 16-character correlation ID. The client uses it to avoid some repeat sends within one live SDK instance, but the ingest service does not deduplicate by this field. Duplicate records remain possible, and overriding it does not provide server-side idempotency. |
| `session_id` | SDK's session ID, renews after `sessionTimeout`. Customer can override.                                                                                                                                                                                                   |
| `event_time` | ISO 8601 timestamp of when the SDK queued the record. Customer can override (occurred-at). Overridden times older than the project's late data window are held for a manual re-backfill instead of charting automatically; see [Late data](/guides/metrics#late-data).    |
| `user_id`    | Included after `identify()` or `userId` is set. Customer can override per-record (B2B/CRM use cases).                                                                                                                                                                     |
| `device_id`  | Included after `setDeviceId()` or `deviceId` is set. Customer can override.                                                                                                                                                                                               |
| `type`       | Event kind within the collection (e.g. `"page_view"`, `"signup"`, `"TypeError"`). Stamped from `track(name, ...)`'s `name` argument or by auto-collectors. Customer can override per-record.                                                                              |
| `sdk`        | SDK package name and version. Included unless the customer provides an `sdk` field.                                                                                                                                                                                       |

Plus any sticky fields from `context` (constructor) or `setContext()`: those also follow the customer-wins rule (only stamp if the record doesn't already have that key).

The collection (routing destination) is **not** a field on the record. It's the URL slug in `?collection=<events|errors|logs|...>`. The record's `type` field discriminates the kind of thing within that collection.

### Reserved field names

These names are reserved for SDK metadata. You can still use them as your own fields and your value will appear on the wire instead of the SDK's. But by convention, treat them as SDK-controlled and use distinct names if you mean something different (e.g. `subject_id` instead of `user_id` to track a CRM contact distinct from the authenticated app user).

```
event_id, event_time, session_id, user_id, device_id, type, sdk
```

## Transport

All requests go to `POST {url}?key={key}&collection={slug}` with `Content-Type: text/plain`. There is no `Authorization` header and no custom request headers; this is a CORS "simple request" with no preflight, so cross-origin installs have no `OPTIONS` round-trip.

Failed requests retry on 5xx, 408, and 429. With the default `maxRetries: 3` and `retryDelay: 1000`, the three retry waits are 1s, 2s, and 4s. Raising `maxRetries` adds later waits such as 8s, capped at 30s. The transport honors the `Retry-After` response header and drops a collection group on other 4xx responses.

On page hide, the browser SDK attempts best-effort delivery with `navigator.sendBeacon`. A `true` return means the browser accepted the data for transfer, not that the ingest service received it. If the browser rejects a buffered beacon and the page remains alive, the SDK requeues those records for a later fetch attempt.

## Hooks

`onBeforeSend` intercepts records before they are queued. Return the record (optionally modified) to keep it, or `null` to drop it. The internal `__tg_collection` field carries the collection routing; read it to filter by collection.

```javascript
const tg = new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_...",
  onBeforeSend: (record) => {
    if (
      record.__tg_collection === "errors" &&
      record.frames?.some((f) => f.filename?.includes("third-party"))
    ) {
      return null;
    }
    record.app_build = "abc123";
    return record;
  }
});
```

## Pipeline order

The common path for records accepted by `track()` or `capture()` is:

```
session touch → sticky sampling → rateLimit gate → stamp → redact → maxRecordBytes check → onBeforeSend → queue → flush → transport
```

- Error and message capture first packages the error, computes its fingerprint, snapshots breadcrumbs, and applies the per-fingerprint `errorBurst` gate. Records that pass then enter the common path above.
- Sticky sampling runs before the global `rateLimit` bucket. A record either gate drops is never stamped, redacted, size-checked, passed to `onBeforeSend`, or queued. Rate-limit drops surface as a collapsed `tglow_rate_limited` self-event.
- `onBeforeSend` sees the post-redaction record. Set `redact.enabled: false` if your hook needs raw payloads.
- `maxRecordBytes` runs before `onBeforeSend`; oversized records never reach the hook.
- `onTransportError` fires for immediate non-retryable 4xx rejections and unexpected exceptions in the send path. Exhausted retryable failures are requeued without calling the hook.

## Lifecycle without auto-collectors

If you want browser-side queueing + visibility flush + sendBeacon delivery but **not** the auto-collectors (Electron renderer, embedded WebView, etc.), disable them all and use `track` manually:

```javascript
const tg = new Tailglow({
  url: "https://{region}.ingest.tailglow.io/{project_id}",
  key: "tg_ingest_...",
  autoPageViews: false,
  autoSections: false,
  autoErrors: false,
  autoVitals: false,
  autoDevice: false,
  autoConsole: [],
  excludeLocalhost: false
});
// Visibility/beforeunload flush via sendBeacon stays active.
```

`excludeLocalhost: false` is required for Electron renderer pages loaded from `file://`, which the browser package excludes by default.

## Typed event schemas

Augment `@tailglow/core`'s `TailglowEventTypes` interface. This is the canonical location whether you use the browser package, the React Native package, or core directly. Event types not in the schema fall through with `Record<string, unknown>`.

The augmented keys describe the `type` field of records sent to the configured `events` collection, not a separate collection per key.

```ts
declare module "@tailglow/core" {
  interface TailglowEventTypes {
    signup: { plan: "free" | "pro" };
    purchase: { amount: number; currency: string };
  }
}

tg.track("signup", { plan: "pro" }); // ✓ typed
tg.track("signup", { plan: "wrong" }); // ✗ TS error
tg.track("anything_else", { whatever: true }); // ✓ falls back to Record<string, unknown>
```

## Platform support

| Platform                                | Auto-collection                                                                                                                                                | Lifecycle                                                                                      | Storage                                                                  |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Browser (`@tailglow/browser`)           | Page views, tagged sections and clicks, declarative and outbound clicks, JS errors, console breadcrumbs plus error/warn records, performance signals, device   | Best-effort `visibilitychange` / `beforeunload` delivery through `sendBeacon`                  | In-memory by default                                                     |
| Node / Bun / edge (`@tailglow/core`)    | None. Call `track()` and capture methods explicitly                                                                                                            | No automatic process or request hook. The caller must await lifecycle methods when needed      | In-memory unless a `storageAdapter` is provided                          |
| React Native (`@tailglow/react-native`) | JS errors, best-effort rejection hooks, console breadcrumbs plus error/warn records; screen views after `attachNavigation()`; device when modules are provided | Injected `AppState` starts best-effort persist, then flush, when leaving active                | Optional `createAsyncStorageAdapter(AsyncStorage)`, newest 1,000 records |
| Electron renderer                       | Use `@tailglow/browser`; set `excludeLocalhost: false` for `file://`                                                                                           | Same best-effort lifecycle as browser                                                          | In-memory by default                                                     |
| Electron main                           | Use `@tailglow/core`                                                                                                                                           | Customer owns the quit lifecycle and must keep the process alive for awaited delivery attempts | In-memory unless a customer adapter is provided                          |
