@tailglow/cli 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/docs.generated.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tailglow/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Tailglow command line. Every command is generated from the Tailglow API reference, so the CLI and the documentation can never describe different APIs.",
|
|
6
6
|
"bin": {
|
package/src/docs.generated.js
CHANGED
|
@@ -43,7 +43,7 @@ export const DOCS_PAGES = {
|
|
|
43
43
|
"guides/servers": "# Servers\n\nServers receive and process data for a project. A source's **Ingest** tab shows the project's endpoint when one is available.\n\n## Trial and paid servers\n\nThe free trial starts one server in your team's first project and grants the team 72 server-hours of compute credit. A payment method is required to add more servers. Credits are shared across projects: two running servers use the same 72-hour balance in 36 hours. Manage Servers shows the monthly cost before you confirm a change.\n\nThe trial starts when the trial server first comes up. Without a payment method, the server pauses when its 72 server-hours run out, and Manage Servers cannot scale it to zero before then. Billable usage ends at the trial deadline, including when cleanup runs late. With a card, the trial ends when the team's server-hours run out, and servers keep running, using any remaining credits before paid usage begins. Unused credits do not expire. Tailglow staff can add server-hours to a trial, which also brings back a paused trial server. See [Billing](/guides/billing#free-server-trial) for month boundaries, resume rules, and the AI and storage allowances every team has.\n\n## Manage server count\n\n1. Open **Servers** in the project sidebar.\n2. Click **Manage Servers**.\n3. Set the desired server count.\n4. Review the consequence and confirm it.\n\nScaling up uses **Add server** or **Add servers** and starts provisioning after you confirm. Scaling down, including scaling to zero, requires a hold-to-confirm action. Scaling to zero stops ingestion, so events sent while no server is running are not stored or recoverable.\n\n### Scaling during a platform update\n\nTailglow updates every server to new platform versions one project at a time, reserving the capacity each update needs before it starts. While an update is in progress, a change to the server count is queued instead of applied: Manage Servers confirms the request, the Servers page shows the queued count, and the change starts automatically when the update finishes. Adding servers to a project that has not finished moving to the current platform version is queued the same way; removing servers is not.\n\nTo cancel a queued change, select **Cancel queued change** on the Servers page, or set the count back to the number of servers the project runs. A newer request replaces an older one. A project with no servers yet is not held back; its first server starts right away. A paused trial server that resumes after you add a payment method also waits for the update to finish.\n\n## Statuses\n\nA new server starts as **provisioning** and becomes **active** after setup. Scaling down changes it to **terminating** while Tailglow drains and removes it. Scaling up later creates a new server.\n\n## Server analytics\n\nServer detail pages show resource usage for the selected server. **Project Ingest Pipeline** shows pending records, throughput, and errors across every server in the project, including temporary deployment servers. You can select a time range or brush across a chart to focus on a smaller window.\n\nThe current queue counts valid spooled records awaiting write, including records being processed. It refreshes independently of the selected chart window. When queues change during the observation, Tailglow counts the durable records it can confirm and marks the pending count with **≈**. If a current server's queue cannot be read or a concurrent write's outcome is unknown, the queue is unavailable instead of showing an uncertain total. A stale reading means the last complete or estimated snapshot is too old to confirm the current queue. The single-line summary shows processed records from the last completed minute alongside the current pending count. The error button appears when the selected chart range contains recorded errors; errors count rejected requests and dropped frames.\n\nThe summary shows a skeleton while its first request loads and keeps existing readings visible during refreshes. Delayed or unavailable measurements are labeled separately, so an available queue or processed count remains visible. A retry control appears after a failed refresh or when neither current reading is available.\n\nHistorical pending values are average queue occupancy, so they can contain fractions. Older history recorded in request frames has gaps in the pending-record series.\n\nThroughput is counted in the minute each spool batch completes. Delayed sampling or shutdown uploads preserve that minute, even when resource measurements for it are unavailable. This does not reconstruct missing historical measurements.\n\nMissing measurements appear as gaps, including when a server's contribution cannot be read. Pending, throughput, and errors can be available independently. Live polling pauses in background tabs.\n\nThe analytics API supports windows up to 48 hours with minute buckets or 30 days with hour buckets. Future end times are limited to the current time.\n\nMeasured values in the current chart bucket show **(so far)** until the bucket ends. For example, at 9:35 an hourly bucket starting at 9:00 is still in progress.\n",
|
|
44
44
|
"guides/ingest-key": "# Ingest Key\n\n## Getting Your Key\n\nYour ingest key is located in your source settings. Select a project, open **Settings**, choose the **Sources** tab, and click the edit button for a source. In the source drawer, select the **Ingest** tab. The **Ingest Keys** section lets you view and manage the source's keys. Each source can have multiple ingest keys.\n\n## Prefix\n\nYour ingest key always begins with the prefix `tg_ingest_`.\n\n## Usage\n\n### Query Parameter (Recommended)\n\nThe simplest way to authenticate is by including your ingest key as a `key` query parameter in the URL. This works across all platforms -- browsers, backends, and mobile apps.\n\n```bash\ncurl -X POST \"https://us11.ingest.tailglow.io/prj_xxx?key=tg_ingest_123\" \\\n -H \"Content-Type: text/plain\" \\\n -d '{\"records\": []}' # Your records here\n```\n\nReplace the URL with your project's ingest endpoint, found in your source settings.\n\nEven though the payload is JSON, setting `Content-Type: text/plain` is safe because the ingest server auto-detects the format. For browser-based clients, this is especially important: `text/plain` is a [simple content type](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests), so browsers skip the CORS preflight OPTIONS request entirely, sending one request instead of two.\n\nThis method also works with `navigator.sendBeacon`, which does not support custom headers:\n\n```javascript\nnavigator.sendBeacon(\n \"https://us11.ingest.tailglow.io/prj_xxx?key=tg_ingest_123\",\n JSON.stringify({ event: \"page_view\", path: \"/home\" })\n);\n```\n\n### Authorization Header\n\nYou can also include your ingest key in the `Authorization` header using the `Bearer` or `Key` prefix:\n\n```bash\ncurl -X POST https://us11.ingest.tailglow.io/prj_xxx \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Key tg_ingest_123\" \\\n -d '{\"records\": []}' # Your records here\n```\n\nNote that from browsers, using a custom `Authorization` header with `Content-Type: application/json` will trigger a CORS preflight request, doubling the number of requests.\n\n## Verify the connection\n\nSend a `GET` request to the same complete ingest URL before uploading. It verifies the key for the destination project and checks whether that server can currently accept data. No request body is needed, and no records or collections are created.\n\n```bash\ncurl -i \"https://us11.ingest.tailglow.io/prj_xxx?key=tg_ingest_123\"\n```\n\nA successful check returns `200 OK` with:\n\n```json\n{ \"status\": \"ready\", \"project_id\": \"prj_xxx\" }\n```\n\nThe `Authorization` header works for verification too. Use the same key and destination you will use for the upload.\n\n| HTTP status | Meaning | What to do |\n| ----------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| `200` | The key is accepted for this project and the server is ready to receive data. | Send your data with `POST` to the same URL. |\n| `401` | The key is missing, invalid, disabled, or belongs to a different project. | Copy the complete ingest URL again from the source's Ingest tab. |\n| `503` | The server cannot currently verify the key or accept data. | Check the response's `error.code` and `message`; wait for the `Retry-After` interval before checking again. |\n\nError responses use the same envelope as uploads, with a top-level `message` and the error code in `error.code`. A `503` with `ingest_auth_unavailable` means the key could not be verified, rather than that it is invalid. `invalid_server_state` means the server is starting or draining; `ingest_spool_full` means its storage writer or capacity is unavailable. A network error or an unreachable project route also means readiness has not been confirmed.\n\nVerification uses the same authentication cache as uploads, and responses are marked `Cache-Control: no-store`. It can update both the key's recent-use time and the project's recent-activity indicator without ingesting any records. A successful check describes the server at that moment: it does not reserve capacity or validate the file you plan to send. Wait for each upload's `202 Accepted` response to confirm durable receipt; downstream processing happens afterward.\n\nThe infrastructure `/health/*` endpoints and CORS `OPTIONS` responses do not verify ingest keys. Use this authenticated `GET` request to check your destination.\n\n## Security\n\nYour ingest key can send data and verify readiness on your ingest server; it cannot read stored records. While it's important to keep this key private, it's acceptable to include it in frontend code if you're using the ingest server for frontend metrics and analytics.\n\n## Managing Your Keys\n\nEach source can have multiple ingest keys. You can create new keys and delete old ones as needed from your source settings. When you need to rotate a key, simply create a new one, update your services to use it, and then delete the old key.\n\n## Key and Team Status\n\nAn ingest key works only when its team status also permits ingestion:\n\n- **Active team**: An active key on an active team works normally.\n- **Delinquent team**: For a non-Enterprise team, an uncollectible or later-unpaid invoice can make the team delinquent. Active ingest keys are then disabled until the payment issue is resolved. A single failed payment attempt does not by itself apply this state.\n- **Restricted team**: The team is read-only and ingestion is disabled until the restriction is resolved.\n- **Blocked team**: The team cannot be accessed and ingestion is disabled. Contact support to resolve this.\n\nAn ingest request is accepted only when both the key and its team are active. If the team becomes `delinquent`, `restricted`, or `blocked`, the key stops accepting data. Update your payment information or contact support as appropriate to restore access.\n",
|
|
45
45
|
"guides/sending-data": "# Sending Data\n\nSend records to the full ingest endpoint copied from a source's **Ingest** tab:\n\n```url\nhttps://{region}.ingest.tailglow.io/{project_id}\n```\n\nInclude an ingest key as the endpoint requires. Do not substitute a generic ingest hostname for your project endpoint.\n\n## Check before uploading\n\nSend a `GET` request to your complete ingest URL, including its key, to verify authentication and current readiness without sending records. A `200 OK` response with `status: \"ready\"` confirms the key and destination are ready for an upload. See [Verify the connection](/guides/ingest-key#verify-the-connection) for examples and failure responses.\n\nUpload with `POST` to the same address and wait for each request's acknowledgement. Readiness can change after the check.\n\n## Upload a file in the app\n\nOpen **Collections → Upload data** to choose an existing collection or create one. From inside a collection, **Upload data** selects that collection for you. Choose an ingest key and an XML, JSONL, CSV, TSV, or JSON file. The app detects the format from a bounded content sample and previews the first records. A sampled preview does not validate the whole file or guarantee every field has been seen.\n\nFor XML, choosing the file is enough. The app automatically imports each entry directly inside the document's root, including different entry types. Nested content stays together. Root attributes and meaningful text are retained in a separate document record; a document containing only its root becomes one record. Each XML record has `name`, `attributes`, and ordered `children`, with attribute values preserved as strings. Line-break indentation in element-only containers is removed; meaningful text, inline spaces, whitespace-only values, CDATA, character references and `xml:space=\"preserve\"` are retained.\n\n**Advanced options** lets you override the detected format or import only part of an XML file using a **Custom XML path**, such as `/HealthData/Record`. Paths use literal element names, including any namespace prefix, rather than XPath expressions. No path selection is required for a normal upload.\n\nXML imports support UTF-8 XML 1.0 with predefined and numeric entities. Namespace names are preserved without resolving namespace URIs. DOCTYPE declarations are framed but not applied; external resources and declared entities are not loaded. HTML is not supported.\n\nCSV and TSV require UTF-8 with unique, non-empty column headers. JSONL requires one JSON object per non-empty line. Whole-document JSON accepts an object or an array of objects, up to 50 MiB. Export Excel or Numbers spreadsheets as CSV first.\n\nStreaming XML, JSONL, CSV and TSV have no total file-size cap in the picker. The browser reads bounded chunks and uploads batches of at most 500 records or 1 MiB. Each serialized record, including its newline, must fit within 1 MiB. Keep the tab open while importing. Large files still require time to read and send, and the destination's normal ingest limits apply.\n\nAfter the preview, choose **Import records** to start. Each record is checked before its batch is sent. You can optionally check the full file first to catch file errors before sending anything. A failed check prevents import; changing the format or XML path creates a new preview. The app also verifies the destination before sending records. If the destination check fails, fix it or choose another key, then select **Check again**.\n\nSource dates stay in the records. Tailglow records when each batch arrives; views and charts can use a source date field instead, such as a historical start time.\n\nThe rows animate as the server acknowledges them. **Stop import** waits for the current request and sends no further batches. Upload requests are not retried automatically; a network failure can leave the last batch's delivery uncertain. A late file error can also stop an import after some batches have arrived. The result reports accepted records and any affected batch range. Check delivery before starting another import, since repeating it can create duplicates. Accepted records may take a moment to appear in the collection.\n\n## Import a file with the CLI\n\nThe CLI requires Node 22 or newer. Scan and validate work locally without credentials:\n\n```bash\ntglow import scan ./export.xml\ntglow import scan ./export.xml --record-path /HealthData/Record\ntglow import validate ./export.xml\n```\n\n`scan` reports a bounded sample, schema, XML paths and possible timestamp fields. Add `--full` to scan to the end. XML uses the same automatic record selection as the app. `validate` reads the entire file. An optional `--record-path` narrows the import; XML outside that path is still checked for syntax errors. `--format xml|jsonl|json|csv|tsv` overrides automatic format detection.\n\nImport into an explicit collection using the complete ingest URL and expected project:\n\n```bash\ntglow import ./export.xml \\\n --ingest-url 'https://{region}.ingest.tailglow.io/{project_id}?key={ingest_key}' \\\n --project '{project_id}' \\\n --collection health_records\n```\n\nTo import only Apple Health measurements, add `--record-path /HealthData/Record`. Without that filter, the file may also contain metadata and other record types. Each record keeps its own dates, such as `startDate`, for views and charts to use.\n\nAlternatively, provide `--project`, `--source-id` and `--ingest-key-id` to resolve that exact key using your configured API credentials. No source or key is chosen automatically.\n\nBy default, upload previews a bounded sample and validates records as it sends batches. It reads through one open file descriptor and stops if it detects a change to the input. Do not edit the file during import. A late file error can leave earlier batches accepted.\n\nAdd `--validate-first` to check a private snapshot completely before uploading that same snapshot. This needs temporary disk space approximately equal to the input size while memory stays bounded. The snapshot is removed after success, failure or cancellation. Separate `scan` and `validate` commands do not upload anything or reserve a later import.\n\nProgress goes to stderr. `--json`, or piping stdout, produces a machine-readable final report. The total row count remains unknown until a full check or clean EOF. Ctrl-C cancels work and reports accepted records plus any uncertain in-flight batch. Only `202 Accepted` acknowledges a batch. The CLI stops on a failed or uncertain delivery and does not retry automatically or resume an earlier import.\n\n## Server formats\n\nThe ingest server accepts JSON objects or arrays, JSONL, CSV, TSV, PSV, SSV, and plain text. JSON needs no wrapper. The app and CLI convert XML into JSONL locally before sending it; the server does not accept raw XML. Input is normalized before storage, so tabular and text payloads become records and dangerous keys or embedded images may be transformed. In a JSON array or JSONL, each value becomes its own record: an object is stored as sent, a number, string, boolean or nested array is stored as `{\"value\": ...}`, and `null`, `{}` and `[]` are skipped.\n\n```bash\ncurl -X POST \"https://{region}.ingest.tailglow.io/{project_id}?key={ingest_key}\" \\\n -H \"Content-Type: text/plain\" \\\n -d '{\"method\":\"POST\",\"path\":\"/v1/users\",\"status\":200}'\n```\n\n## Response and delivery\n\nA successful request returns `202 Accepted`. Tailglow then processes the records in the background.\n\nWait for that response, use the SDK queue, or otherwise record delivery failures. You do not need to wait for downstream processing, but a request can be lost if the caller exits before delivery completes.\n\nThe maximum server request body is 20 MiB (20,971,520 bytes). A larger body is rejected with `413 Payload Too Large`; split it into smaller requests. File imports use the smaller 1 MiB batch limit described above.\n\n## Errors and retries\n\nA `202 Accepted` response means the data is durably accepted: it is queued on your server and retried internally until it lands. Accepted data is never dropped.\n\nAn explicit ingest rejection means nothing was recorded from that request. A lost response can leave delivery uncertain. A `4xx` response means the request itself must change, so fix the key, payload, or size before resending.\n\nThe official SDKs retry transient failures automatically with backoff. App and CLI file imports stop and report instead, allowing you to check an uncertain batch before deciding whether to resend it. Customer-supplied idempotency keys are not supported by this import flow.\n\nRefused requests appear on the server page's Ingest Pipeline chart, grouped by reason. The error drawer marks each group as retryable or needing a change to the request. The full reason list is on the [Enums](/api/enums#ingest-pipeline-error-reasons) page.\n",
|
|
46
|
-
"guides/sdk": "# JavaScript SDK\n\nTailglow'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.\n\nThe 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.\n\nNew here? The **[Quickstart](/guides/quickstart)** gets a web app reporting in about two minutes. This page is the full reference.\n\n## Runtimes\n\n| Runtime | Package | Automatic out of the box |\n| ------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **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` |\n| **Backend** (Node, Bun, edge) | `@tailglow/core` | Nothing automatic: you `track()` explicitly and flush before the process or request ends |\n| **Electron** | renderer uses `@tailglow/browser`, main uses `@tailglow/core` | Renderer behaves like a browser after allowing `file://`; main behaves like a backend |\n| **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 |\n\nEvery 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.\n\n## Installation\n\n```bash\nbun add @tailglow/browser\n# or\nnpm install @tailglow/browser\n```\n\n## Drop-in `<script>` (no build step)\n\nFor 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:\n\n```html\n<script\n defer\n src=\"https://cdn.tailglow.io/tglow.js\"\n data-key=\"tg_ingest_your_key\"\n data-url=\"https://{region}.ingest.tailglow.io/{project_id}\"\n></script>\n```\n\nOptionally, add a pre-load stub before the loader so calls made before the SDK finishes loading are queued and replayed on init:\n\n```html\n<script>\n // Optional pre-load stub. Calls made before the SDK loads are replayed on init.\n window.tglow =\n window.tglow ||\n function () {\n (window.tglow.q = window.tglow.q || []).push(arguments);\n };\n\n tglow(\"track\", \"signup\", { plan: \"pro\" });\n tglow(\"identify\", \"user_123\");\n</script>\n```\n\nOverride the default collection routing via additional data-attrs (rare; defaults are `events` / `errors` / `logs`):\n\n```html\n<script\n defer\n src=\"https://cdn.tailglow.io/tglow.js\"\n data-key=\"tg_ingest_...\"\n data-url=\"https://{region}.ingest.tailglow.io/{project_id}\"\n data-collection-events=\"myapp_events\"\n data-collection-errors=\"myapp_errors\"\n data-collection-logs=\"myapp_logs\"\n></script>\n```\n\nSet all three to the same slug to merge everything into one timeline collection.\n\n### Declarative event tracking\n\n`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.\n\n```html\n<button data-tglow-event=\"signup\" data-tglow-method=\"github\">Sign up with GitHub</button>\n<a href=\"/pricing\" data-tglow-event=\"pricing_click\" data-tglow-source=\"hero\">See pricing</a>\n```\n\n## Initializing\n\nThe full browser walkthrough is in the [Quickstart](/guides/quickstart). Below is the minimal init per runtime.\n\n### Browser\n\n```javascript\nimport { Tailglow } from \"@tailglow/browser\";\n\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\n// Auto-collection starts immediately: page views, section visibility,\n// errors, performance signals, and device info.\n\ntg.track(\"signup\", { plan: \"pro\" });\ntg.identify(\"user_123\");\n```\n\n### Node / Bun (via `@tailglow/core`)\n\n```javascript\nimport { TailglowCore } from \"@tailglow/core\";\n\nconst tg = new TailglowCore({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\ntg.track(\"job_completed\", { job_id: \"abc\" });\n\nprocess.on(\"SIGTERM\", async () => {\n await tg.flush();\n await tg.destroy();\n});\n```\n\n### React Native (`@tailglow/react-native`)\n\n```javascript\n// App.tsx\nimport AsyncStorage from \"@react-native-async-storage/async-storage\";\nimport { NavigationContainer, useNavigationContainerRef } from \"@react-navigation/native\";\nimport { createAsyncStorageAdapter, Tailglow } from \"@tailglow/react-native\";\nimport { useEffect } from \"react\";\nimport { AppState, Dimensions, NativeModules, Platform } from \"react-native\";\n\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n appState: AppState,\n platform: Platform,\n dimensions: Dimensions.get(\"window\"),\n nativeConstants: NativeModules.PlatformConstants,\n storageAdapter: createAsyncStorageAdapter(AsyncStorage)\n});\n\nexport default function App() {\n const navigationRef = useNavigationContainerRef();\n useEffect(() => {\n tg.attachNavigation(navigationRef); // → automatic page_view on every screen change\n }, []);\n return <NavigationContainer ref={navigationRef}>{/* screens */}</NavigationContainer>;\n}\n```\n\nWith 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()`).\n\nThis 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.\n\n## Frameworks\n\nAny framework can use the SDK. In an existing app, four things decide whether it reports correctly.\n\n### Next.js and SvelteKit\n\nBoth frameworks render pages on the server before the browser runs them. `@tailglow/browser` needs a browser: created during server rendering, it logs an error and records nothing. Keep one instance in a module, and create it the first time the app runs in the browser:\n\n```javascript\n// lib/tailglow.js\nimport { Tailglow } from \"@tailglow/browser\";\n\nlet tg = null;\n\nexport function getTailglow() {\n tg ??= new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n });\n return tg;\n}\n\n// On sign-out or an account switch: the next getTailglow() starts a fresh instance.\nexport async function resetTailglow() {\n const previous = tg;\n tg = null;\n // destroy() removes the old collectors before it awaits anything, then sends what is queued.\n await previous?.destroy();\n}\n```\n\nIn SvelteKit, call it from the root layout:\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script>\n import { getTailglow } from \"$lib/tailglow.js\";\n import { onMount } from \"svelte\";\n\n let { children } = $props();\n\n onMount(() => {\n getTailglow();\n });\n</script>\n\n{@render children()}\n```\n\nIn Next.js (App Router), call it from a client component, and render `<TailglowInit />` once inside `<body>` in `app/layout.jsx`:\n\n```javascript\n// app/tailglow-init.jsx\n\"use client\";\n\nimport { useEffect } from \"react\";\nimport { getTailglow } from \"../lib/tailglow.js\";\n\nexport function TailglowInit() {\n useEffect(() => {\n getTailglow();\n }, []);\n return null;\n}\n```\n\nElsewhere in browser code, call `getTailglow().track(...)`. Page views follow client-side navigation on their own.\n\n### One instance per page\n\nEvery instance installs its own collectors, so two instances count every page view, click and error twice. Create it once, as above, never in a component body or an effect that can run again. Do not destroy it in an effect's cleanup either: React's Strict Mode runs the cleanup and the effect a second time in development, and the module would keep a destroyed instance.\n\n### Serverless functions\n\n`@tailglow/core` sends records in batches every 30 seconds by default. A serverless function can be frozen as soon as it returns, with records still queued. In a Node or Bun function, such as AWS Lambda, create the instance outside the handler, so warm invocations share it, and flush before returning:\n\n```javascript\nimport { TailglowCore } from \"@tailglow/core\";\n\nconst tg = new TailglowCore({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\nexport async function handler(event) {\n tg.track(\"order_placed\", { order_id: event.order_id });\n await tg.flush();\n return { statusCode: 200 };\n}\n```\n\nWhere the platform offers `waitUntil`, pass it `tg.flush()` instead, so the response is not held up. A script that exits on its own should end with `await tg.destroy()`: until then, the SDK's send timer keeps the process running.\n\nCloudflare Workers allow no timers or random values at module scope, and the SDK starts both when it is created. Create it inside the handler there, and hand its shutdown to `waitUntil`:\n\n```javascript\nimport { TailglowCore } from \"@tailglow/core\";\n\nexport default {\n async fetch(request, env, ctx) {\n const tg = new TailglowCore({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n });\n tg.track(\"api_request\", { path: new URL(request.url).pathname });\n ctx.waitUntil(tg.destroy());\n return new Response(\"ok\");\n }\n};\n```\n\n### Fitting into an existing app\n\n- **Sign-in.** Call `identify()` where the app learns who is signed in, and again on each page load of a signed-in session, because the browser SDK keeps no identity between loads. On sign-out or an account switch, `await resetTailglow()` so the next `getTailglow()` starts a fresh instance; [Identity changes](#identity-changes-logout--login--org-switch) explains why there is no reset in place.\n- **Existing error reporting.** With `autoErrors` on, Tailglow and any other error reporter both receive every uncaught error. Keep both, or set `autoErrors: false` and send Tailglow only the errors you choose.\n\n## Identity model\n\nBrowser 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.\n\n| Layer | Source |\n| ------------ | -------------------------------------------------------------------------------------------------- |\n| `session_id` | In-memory, regenerated after `sessionTimeout` of inactivity |\n| `user_id` | `identify(user_id)`. Same-session records can be backfilled until their request body is serialized |\n| `device_id` | `setDeviceId(id)`. Opt-in, customer-supplied (mobile/desktop) |\n\nSticky sampling resolves in cascade: `user_id` → `device_id` → `session_id`. Without an identifier above session, sampling resets per session (the privacy-preserving default).\n\n## Public API\n\n### Collections model\n\nThe SDK manages three collections (configurable; defaults shown):\n\n| Collection | What lands here |\n| ---------- | --------------------------------------------------------------------------------------------------------------- |\n| `events` | Every `track()` call and every auto-collected event (page views, clicks, vitals, device snapshots, navigation). |\n| `errors` | `captureException`, `captureMessage`, and `console.error`. |\n| `logs` | Non-error console levels selected by `autoConsole`. By default only `console.warn` emits here. |\n\nRecords 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.\n\n```javascript\ntg.track(\"signup\", { plan: \"pro\" });\n// → POST ?collection=events with { type: \"signup\", plan: \"pro\", session_id: ... }\n\ntg.track(\"purchase\", { amount: 99 });\n// → POST ?collection=events with { type: \"purchase\", amount: 99, session_id: ... }\n```\n\nOverride 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()`:\n\n```javascript\ntg.track({ type: \"audit_event\", collection: \"audit_log\", actor: \"user_42\" });\n// → POST ?collection=audit_log with { type: \"audit_event\", actor: \"user_42\", ... }\n```\n\nThe 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.\n\nConfigure the destinations at construction time:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n collections: {\n events: \"events\", // default; override to namespace per app on a shared source\n errors: \"errors\",\n logs: \"logs\"\n }\n});\n```\n\nSet all three to the same slug to merge everything into one timeline collection:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n collections: { events: \"timeline\", errors: \"timeline\", logs: \"timeline\" }\n});\n```\n\n### Sending events\n\n`track(name, props?)` (string form)\nThe `name` parameter is the **event type**, stamped as the `type` field on the record. Routes to the configured `collections.events` destination (default: `events`).\n\n`track({ type, collection?, ...props })` (object form)\nUse 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.\n\n```javascript\ntg.track(\"signup\", { plan: \"pro\" });\ntg.track(\"purchase\", { amount: 99, currency: \"USD\" });\n```\n\nCustomer-supplied `props.type` wins on collision: pass `type: \"...\"` explicitly in props to override the SDK's stamp.\n\n### Errors and exceptions\n\n`captureException(err, props?)`\nCapture 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.\n\n```javascript\ntry {\n riskyOperation();\n} catch (err) {\n tg.captureException(err, { user_step: \"checkout\" });\n}\n```\n\n`level` defaults to `\"error\"`. Override via props:\n\n```javascript\ntg.captureException(err, { level: \"fatal\", user_step: \"checkout\" });\n```\n\n`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:\n\n```javascript\ntg.captureException(err, { fingerprint: \"payment-flow\" });\n```\n\n`captureMessage(message, props?)`\nCapture 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\"`.\n\n```javascript\ntg.captureMessage(\"payment validator returned null\", { level: \"warning\" });\n```\n\n`addBreadcrumb(entry)`\nPush 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.\n\n```javascript\ntg.addBreadcrumb({\n category: \"navigation\",\n message: \"/home → /checkout\",\n timestamp: Date.now()\n});\n```\n\n#### Auto-captured errors and rejections\n\nWhen `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\"`).\n\n#### Auto-captured console activity\n\nWhen `autoConsole` uses its default value of `[\"error\", \"warn\"]`, all supported `console.*` calls are wrapped for breadcrumbs, but only warnings and errors emit records:\n\n| Console method | Default effect | Record destination | `type` |\n| --------------------------- | ------------------------------------------------ | ------------------- | ---------------- |\n| `console.log` | breadcrumb only | none | n/a |\n| `console.warn` | record + breadcrumb | `logs` collection | `\"warn\"` |\n| `console.info` | breadcrumb only | none | n/a |\n| `console.debug` | breadcrumb only | none | n/a |\n| `console.error(err: Error)` | `captureException` + breadcrumb | `errors` collection | error class name |\n| `console.error(string)` | `captureMessage` (level: `\"error\"`) + breadcrumb | `errors` collection | `\"Message\"` |\n\nThe 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.\n\nWhen a non-error level is included in `autoConsole`, its record follows these argument-shape rules:\n\n```javascript\nconsole.log(\"user clicked save\");\n// → { type: \"log\", message: \"user clicked save\" }\n\nconsole.log({ user_id: 42, action: \"save\" });\n// → { type: \"log\", user_id: 42, action: \"save\", message: \"{...}\" } (object flattened)\n\nconsole.log(\"user clicked\", { id: 42, name: \"Alice\" });\n// → { type: \"log\", message: \"user clicked {...}\", id: 42, name: \"Alice\" }\n```\n\n#### Burst protection\n\nTo 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.\n\nConfigurable via:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n errorBurst: { threshold: 10, window_ms: 1000, cooldown_ms: 120_000 }\n});\n```\n\nThe fingerprint counter map is LRU-evicted at 1000 entries to bound memory in long-running sessions.\n\nUnderneath 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.\n\n#### `errors` collection record shape\n\n```json\n{\n \"type\": \"TypeError\",\n \"message\": \"Cannot read 'x' of null\",\n \"stack_raw\": \"TypeError: Cannot...\",\n \"frames\": [\n {\n \"filename\": \"https://cdn.example.com/app.min.js\",\n \"function\": \"calculateTotal\",\n \"lineno\": 1,\n \"colno\": 48201,\n \"in_app\": true\n }\n ],\n \"fingerprint\": \"9c3f8a1b\",\n \"mechanism\": { \"type\": \"uncaught\", \"handled\": false },\n \"level\": \"error\",\n \"cause\": { \"type\": \"Error\", \"message\": \"...\", \"frames\": [] },\n \"breadcrumbs\": [\n { \"category\": \"console\", \"level\": \"log\", \"message\": \"...\", \"timestamp\": 1700000000000 }\n ],\n \"sdk\": { \"name\": \"tailglow.browser\", \"version\": \"x.y.z\" }\n}\n```\n\n#### `logs` collection record shape\n\n```json\n{\n \"type\": \"log\",\n \"message\": \"user clicked save\",\n \"session_id\": \"sess_...\",\n \"event_id\": \"...\",\n \"event_time\": \"...\",\n \"sdk\": { \"name\": \"tailglow.browser\", \"version\": \"x.y.z\" }\n}\n```\n\n### Identity & context\n\n`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.\n\n`unidentify()`: clear the current user ID. Equivalent to `identify(\"\")` but explicit.\n\n`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.\n\n`rotateSession()`: force a new session ID at an explicit boundary (workflow finished, end-of-flow). Returns the new ID.\n\n`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.\n\n`clearContext()`: drop everything previously added via `setContext`.\n\n```javascript\ntg.identify(\"usr_123\");\ntg.setContext({\n release: \"v2.1.0\",\n environment: \"production\",\n email: \"alice@example.com\",\n plan: \"pro\",\n region: \"us-west\",\n subscription: { tier: \"pro\", seats: 12 }\n});\n```\n\nTailglow 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.\n\n#### Sticky-sample identity transition\n\nSampling 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.\n\n#### Identity changes (logout / login / org switch)\n\nThere is no `reset()` method. The pattern is flush, destroy, reconstruct:\n\n```javascript\nawait tg.flush(); // attempt to drain pending records under the old identity\nawait tg.destroy(); // remove listeners, stop the queue interval\ntg = new Tailglow({ ...sdkConfig });\n```\n\n### Opt-out and privacy\n\nThe 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).\n\nTo let a user turn analytics off:\n\n`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.\n`optIn()` / `isOptedOut()`: resume tracking / check status.\n\n```javascript\ntg.optOut(); // drop buffered records and stop accepting new records in the core\ntg.optIn(); // resume\n```\n\nFor 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.\n\n```javascript\nif (userAccepted) tg = new Tailglow({ ...config });\n// later, to revoke:\ntg.optOut();\nawait tg.destroy();\n```\n\n### Lifecycle\n\n`flush()`: async; attempt to flush all queued records and wait for the current flush cycle. Retryable failures that exhaust their transport attempts remain queued.\n`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.\n`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.\n\n#### Exit-time delivery\n\nBrowser 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.\n\n## Configuration\n\nAll options have sensible defaults. Only `url` is required, and it may already carry the key.\n\n### Core options (all packages)\n\n| Option | Type | Default | Description |\n| -------------------------- | --------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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=`. |\n| `key` | `string` | optional | Ingest key (starts with `tg_ingest_`). Omit it when `url` carries one; supplying it overrides the carried key. |\n| `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`. |\n| `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. |\n| `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. |\n| `flushInterval` | `number` | `30000` | Auto-flush interval in ms |\n| `flushSize` | `number` | `100` | Auto-flush at this record count |\n| `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. |\n| `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. |\n| `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. |\n| `sessionTimeout` | `number` | `1800000` | Session timeout (30 min) |\n| `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`. |\n| `maxRetries` | `number` | `3` | Retry attempts after network errors and 5xx, 408, or 429 responses. Honors `Retry-After`. |\n| `retryDelay` | `number` | `1000` | Initial retry delay (doubles each attempt) |\n| `sampleRate` | `number` | `1.0` | Sticky sampling rate (0 to 1) |\n| `deviceId` | `string` | - | Customer-supplied device ID for this SDK instance. The caller owns persistence across launches. |\n| `userId` | `string` | - | Initial user ID |\n| `storageAdapter` | `StorageAdapter` | - | Async storage adapter for non-browser persistence |\n| `onBeforeSend` | `(record) => record \\| null` | - | Filter/enrich records, return null to drop |\n| `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. |\n| `redact.enabled` | `boolean` | `true` | Master redaction switch |\n| `redact.redactEmails` | `boolean` | `true` | Redact email-shaped strings |\n| `redact.redactFields` | `string[]` | - | Drop these top-level fields |\n| `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. |\n| `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. |\n| `errorBurst` | `object` | see above | Per-fingerprint client-side rate limit. Keys: `threshold` (default 10), `window_ms` (1000), `cooldown_ms` (120000), `max_entries` (1000). |\n| `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. |\n| `breadcrumbBuffer` | `number` | `100` | Ring buffer size for breadcrumbs. |\n| `debug` | `boolean` | `false` | Console logging |\n\n**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)`.\n\n### Browser-only options (`@tailglow/browser`)\n\n| Option | Type | Default | Description |\n| ----------------------- | ------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `autoPageViews` | `boolean` | `true` | SPA navigation tracking |\n| `autoSections` | `boolean` | `true` | `data-telemetry` element visibility + clicks |\n| `autoErrors` | `boolean` | `true` | Uncaught errors and unhandled rejections |\n| `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. |\n| `autoDevice` | `boolean` | `true` | Send device info on init |\n| `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. |\n| `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. |\n| `spaMode` | `\"auto\" \\| \"history\" \\| \"hash\" \\| \"off\"` | `\"auto\"` | SPA navigation strategy |\n| `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. |\n| `sectionAttribute` | `string` | `\"data-telemetry\"` | Attribute name for section tracking |\n| `intersectionThreshold` | `number` | `0.5` | Visibility ratio for \"seen\" |\n| `honorDnt` | `boolean` | `true` | Honor `navigator.doNotTrack` |\n| `honorGpc` | `boolean` | `true` | Honor `navigator.globalPrivacyControl` |\n| `excludeLocalhost` | `boolean` | `true` | Skip tracking on localhost / file:// |\n| `respectDevOptOut` | `boolean` | `false` | Read `tglow_ignore` localStorage flag |\n\n## Privacy defaults\n\nThe browser SDK is designed to operate without persistent client storage:\n\n| Default | Behavior |\n| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Honor DNT | If `navigator.doNotTrack === \"1\"`, the SDK is fully disabled. |\n| Honor GPC | If `navigator.globalPrivacyControl === true`, the SDK is fully disabled. |\n| Skip localhost | The SDK does not track on `localhost`, `127.0.0.1`, `0.0.0.0`, or `file://`. |\n| No browser storage | The default browser path does **not** read or write cookies, localStorage, sessionStorage, or IndexedDB. |\n| 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. |\n\nThe `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.\n\n### Console capture\n\nDefault `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.\n\nTo broaden capture, list more levels. To disable entirely, pass `[]`.\n\n```javascript\n// Default: errors and warnings as records, all levels in breadcrumbs\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\n// Errors only on the wire, breadcrumbs still capture from log/warn/info/debug\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n autoConsole: [\"error\"]\n});\n\n// Capture everything (data-lake mode)\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n autoConsole: [\"log\", \"warn\", \"info\", \"debug\", \"error\"]\n});\n\n// Disable entirely: no wrapping, no breadcrumbs from console\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n autoConsole: []\n});\n```\n\nRouting per level when emitted:\n\n- `log` / `warn` / `info` / `debug` → configured `logs` collection with `type: \"<level>\"`\n- `error(Error)` → `captureException` → configured `errors` collection with `type` = error class name\n- `error(string)` → `captureMessage` (level: `\"error\"`) → configured `errors` collection with `type: \"Message\"`\n\n**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.\n\n**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.\n\n## Testing on localhost\n\nBy 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`.\n\n### ESM\n\n```javascript\nimport { Tailglow } from \"@tailglow/browser\";\n\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n excludeLocalhost: false, // enable on localhost\n context: { environment: \"development\" }, // tag dev events so they're filterable\n debug: true // log SDK activity to the console\n});\n```\n\n### Drop-in script\n\n```html\n<script\n defer\n src=\"https://cdn.tailglow.io/tglow.js\"\n data-key=\"tg_ingest_your_key\"\n data-url=\"https://{region}.ingest.tailglow.io/{project_id}\"\n data-exclude-localhost=\"false\"\n data-environment=\"development\"\n data-debug=\"true\"\n></script>\n```\n\n(`data-release`, `data-environment`, and `data-dist` flow into `context` automatically. For richer context, call `tglow(\"setContext\", {...})` after the script loads.)\n\n### Recommended: tag dev events with a distinct `environment`\n\nWhen 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.\n\nA 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:\n\n```javascript\nconst environment = import.meta.env.MODE;\n\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n excludeLocalhost: import.meta.env.PROD,\n context: { environment }\n});\n```\n\n### Want to disable in dev too? Use `tglow_ignore`\n\nThe `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`:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n respectDevOptOut: true\n});\n```\n\nThen in the browser console: `localStorage.setItem(\"tglow_ignore\", \"true\")`. SDK will be disabled on next load.\n\n## Auto-collected events (browser)\n\nBrowser 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 or metrics to distinguish `page_view`, `click`, `vital`, and other record kinds.\n\n### Page views: `type = \"page_view\"`\n\n```json\n{\n \"type\": \"page_view\",\n \"from_path\": \"/\",\n \"to_path\": \"/pricing\",\n \"duration_ms\": 4500,\n \"nav_type\": \"push\",\n \"url\": \"https://example.com/pricing\"\n}\n```\n\nThe URL field uses `<link rel=\"canonical\">` if present, otherwise `window.location.href`.\n\n### Section visibility & clicks\n\n`type = \"section_view\"` | `\"click\"` | `\"outbound_click\"` | `\"scroll_depth\"`.\n\n```html\n<section data-telemetry=\"hero\">...</section>\n```\n\n```json\n// section_view\n{ \"type\": \"section_view\", \"section\": \"hero\", \"path\": \"/\", \"duration_ms\": 8200 }\n// click\n{ \"type\": \"click\", \"section\": \"pricing\", \"path\": \"/\", \"tag\": \"button\", \"text\": \"Sign up\" }\n// outbound_click\n{ \"type\": \"outbound_click\", \"href\": \"https://github.com/...\", \"text\": \"View on GitHub\", \"path\": \"/\" }\n// scroll_depth\n{ \"type\": \"scroll_depth\", \"depth_percent\": 85, \"path\": \"/\" }\n```\n\n### Errors: `errors` collection\n\n```json\n{\n \"type\": \"TypeError\",\n \"message\": \"Cannot read 'x' of null\",\n \"stack_raw\": \"TypeError: Cannot read 'x' of null\\n at ...\",\n \"frames\": [\n {\n \"filename\": \"https://cdn.example.com/app.min.js\",\n \"function\": \"calculateTotal\",\n \"lineno\": 1,\n \"colno\": 48201,\n \"in_app\": true\n }\n ],\n \"fingerprint\": \"9c3f8a1b\",\n \"mechanism\": { \"type\": \"uncaught\", \"handled\": false },\n \"level\": \"error\",\n \"breadcrumbs\": [\n { \"category\": \"console\", \"level\": \"log\", \"message\": \"...\", \"timestamp\": 1700000000000 }\n ]\n}\n```\n\n`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\"`).\n\n### Performance signals: `type = \"vital\"`\n\n```json\n{ \"type\": \"vital\", \"vital_name\": \"LCP\", \"vital_value\": 1200, \"path\": \"/\" }\n{ \"type\": \"vital\", \"vital_name\": \"CLS\", \"vital_value\": 0.05, \"path\": \"/\" }\n```\n\nThese 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.\n\n### Device: `type = \"device\"`\n\nSent once on init. Includes browser, OS, device type, screen, viewport, language, timezone, connection (effectiveType / downlink / rtt), referrer source, and UTM parameters.\n\n### Session summary: `type = \"session_summary\"`\n\n```json\n{\n \"type\": \"session_summary\",\n \"session_id\": \"sess_Abc123\",\n \"engaged_ms\": 145200,\n \"pages_viewed\": 7,\n \"entry_path\": \"/\",\n \"exit_path\": \"/pricing\",\n \"reason\": \"hide\"\n}\n```\n\nEmitted 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`.\n\n## Record metadata\n\nThe 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.\n\n| Field | Description |\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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. |\n| `session_id` | SDK's session ID, renews after `sessionTimeout`. Customer can override. |\n| `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). |\n| `user_id` | Included after `identify()` or `userId` is set. Customer can override per-record (B2B/CRM use cases). |\n| `device_id` | Included after `setDeviceId()` or `deviceId` is set. Customer can override. |\n| `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. |\n| `sdk` | SDK package name and version. Included unless the customer provides an `sdk` field. |\n\nPlus 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).\n\nThe 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.\n\n### Reserved field names\n\nThese 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).\n\n```\nevent_id, event_time, session_id, user_id, device_id, type, sdk\n```\n\n## Transport\n\nAll 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.\n\nFailed 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.\n\nOn 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.\n\n## Hooks\n\n`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.\n\n```javascript\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n onBeforeSend: (record) => {\n if (\n record.__tg_collection === \"errors\" &&\n record.frames?.some((f) => f.filename?.includes(\"third-party\"))\n ) {\n return null;\n }\n record.app_build = \"abc123\";\n return record;\n }\n});\n```\n\n## Pipeline order\n\nThe common path for records accepted by `track()` or `capture()` is:\n\n```\nsession touch → sticky sampling → rateLimit gate → stamp → redact → maxRecordBytes check → onBeforeSend → queue → flush → transport\n```\n\n- 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.\n- 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.\n- `onBeforeSend` sees the post-redaction record. Set `redact.enabled: false` if your hook needs raw payloads.\n- `maxRecordBytes` runs before `onBeforeSend`; oversized records never reach the hook.\n- `onTransportError` fires for immediate non-retryable 4xx rejections and unexpected exceptions in the send path. Exhausted retryable failures are requeued without calling the hook.\n\n## Lifecycle without auto-collectors\n\nIf 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:\n\n```javascript\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n autoPageViews: false,\n autoSections: false,\n autoErrors: false,\n autoVitals: false,\n autoDevice: false,\n autoConsole: [],\n excludeLocalhost: false\n});\n// Visibility/beforeunload flush via sendBeacon stays active.\n```\n\n`excludeLocalhost: false` is required for Electron renderer pages loaded from `file://`, which the browser package excludes by default.\n\n## Typed event schemas\n\nAugment `@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>`.\n\nThe augmented keys describe the `type` field of records sent to the configured `events` collection, not a separate collection per key.\n\n```ts\ndeclare module \"@tailglow/core\" {\n interface TailglowEventTypes {\n signup: { plan: \"free\" | \"pro\" };\n purchase: { amount: number; currency: string };\n }\n}\n\ntg.track(\"signup\", { plan: \"pro\" }); // ✓ typed\ntg.track(\"signup\", { plan: \"wrong\" }); // ✗ TS error\ntg.track(\"anything_else\", { whatever: true }); // ✓ falls back to Record<string, unknown>\n```\n\n## Platform support\n\n| Platform | Auto-collection | Lifecycle | Storage |\n| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |\n| 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 |\n| 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 |\n| 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 |\n| Electron renderer | Use `@tailglow/browser`; set `excludeLocalhost: false` for `file://` | Same best-effort lifecycle as browser | In-memory by default |\n| 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 |\n",
|
|
46
|
+
"guides/sdk": "# JavaScript SDK\n\nTailglow'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.\n\nThe 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.\n\nNew here? The **[Quickstart](/guides/quickstart)** gets a web app reporting in about two minutes. This page is the full reference.\n\n## Runtimes\n\n| Runtime | Package | Automatic out of the box |\n| ------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **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` |\n| **Backend** (Node, Bun, edge) | `@tailglow/core` | Nothing automatic: you `track()` explicitly and flush before the process or request ends |\n| **Electron** | renderer uses `@tailglow/browser`, main uses `@tailglow/core` | Renderer behaves like a browser after allowing `file://`; main behaves like a backend |\n| **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 |\n\nEvery 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.\n\n## Installation\n\n```bash\nbun add @tailglow/browser\n# or\nnpm install @tailglow/browser\n```\n\n## Drop-in `<script>` (no build step)\n\nFor 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:\n\n```html\n<script\n defer\n src=\"https://cdn.tailglow.io/tglow.js\"\n data-key=\"tg_ingest_your_key\"\n data-url=\"https://{region}.ingest.tailglow.io/{project_id}\"\n></script>\n```\n\nOptionally, add a pre-load stub before the loader so calls made before the SDK finishes loading are queued and replayed on init:\n\n```html\n<script>\n // Optional pre-load stub. Calls made before the SDK loads are replayed on init.\n window.tglow =\n window.tglow ||\n function () {\n (window.tglow.q = window.tglow.q || []).push(arguments);\n };\n\n tglow(\"track\", \"signup\", { plan: \"pro\" });\n tglow(\"identify\", \"user_123\");\n</script>\n```\n\nOverride the default collection routing via additional data-attrs (rare; defaults are `events` / `errors` / `logs`):\n\n```html\n<script\n defer\n src=\"https://cdn.tailglow.io/tglow.js\"\n data-key=\"tg_ingest_...\"\n data-url=\"https://{region}.ingest.tailglow.io/{project_id}\"\n data-collection-events=\"myapp_events\"\n data-collection-errors=\"myapp_errors\"\n data-collection-logs=\"myapp_logs\"\n></script>\n```\n\nSet all three to the same slug to merge everything into one timeline collection.\n\n### Declarative event tracking\n\n`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.\n\n```html\n<button data-tglow-event=\"signup\" data-tglow-method=\"github\">Sign up with GitHub</button>\n<a href=\"/pricing\" data-tglow-event=\"pricing_click\" data-tglow-source=\"hero\">See pricing</a>\n```\n\n## Initializing\n\nThe full browser walkthrough is in the [Quickstart](/guides/quickstart). Below is the minimal init per runtime.\n\n### Browser\n\n```javascript\nimport { Tailglow } from \"@tailglow/browser\";\n\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\n// Auto-collection starts immediately: page views, section visibility,\n// errors, performance signals, and device info.\n\ntg.track(\"signup\", { plan: \"pro\" });\ntg.identify(\"user_123\");\n```\n\n### Node / Bun (via `@tailglow/core`)\n\n```javascript\nimport { TailglowCore } from \"@tailglow/core\";\n\nconst tg = new TailglowCore({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\ntg.track(\"job_completed\", { job_id: \"abc\" });\n\nprocess.on(\"SIGTERM\", async () => {\n await tg.flush();\n await tg.destroy();\n});\n```\n\n### React Native (`@tailglow/react-native`)\n\n```javascript\n// App.tsx\nimport AsyncStorage from \"@react-native-async-storage/async-storage\";\nimport { NavigationContainer, useNavigationContainerRef } from \"@react-navigation/native\";\nimport { createAsyncStorageAdapter, Tailglow } from \"@tailglow/react-native\";\nimport { useEffect } from \"react\";\nimport { AppState, Dimensions, NativeModules, Platform } from \"react-native\";\n\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n appState: AppState,\n platform: Platform,\n dimensions: Dimensions.get(\"window\"),\n nativeConstants: NativeModules.PlatformConstants,\n storageAdapter: createAsyncStorageAdapter(AsyncStorage)\n});\n\nexport default function App() {\n const navigationRef = useNavigationContainerRef();\n useEffect(() => {\n tg.attachNavigation(navigationRef); // → automatic page_view on every screen change\n }, []);\n return <NavigationContainer ref={navigationRef}>{/* screens */}</NavigationContainer>;\n}\n```\n\nWith 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()`).\n\nThis 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.\n\n## Frameworks\n\nAny framework can use the SDK. In an existing app, four things decide whether it reports correctly.\n\n### Next.js and SvelteKit\n\nBoth frameworks render pages on the server before the browser runs them. `@tailglow/browser` needs a browser: created during server rendering, it logs an error and records nothing. Keep one instance in a module, and create it the first time the app runs in the browser:\n\n```javascript\n// lib/tailglow.js\nimport { Tailglow } from \"@tailglow/browser\";\n\nlet tg = null;\n\nexport function getTailglow() {\n tg ??= new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n });\n return tg;\n}\n\n// On sign-out or an account switch: the next getTailglow() starts a fresh instance.\nexport async function resetTailglow() {\n const previous = tg;\n tg = null;\n // destroy() removes the old collectors before it awaits anything, then sends what is queued.\n await previous?.destroy();\n}\n```\n\nIn SvelteKit, call it from the root layout:\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script>\n import { getTailglow } from \"$lib/tailglow.js\";\n import { onMount } from \"svelte\";\n\n let { children } = $props();\n\n onMount(() => {\n getTailglow();\n });\n</script>\n\n{@render children()}\n```\n\nIn Next.js (App Router), call it from a client component, and render `<TailglowInit />` once inside `<body>` in `app/layout.jsx`:\n\n```javascript\n// app/tailglow-init.jsx\n\"use client\";\n\nimport { useEffect } from \"react\";\nimport { getTailglow } from \"../lib/tailglow.js\";\n\nexport function TailglowInit() {\n useEffect(() => {\n getTailglow();\n }, []);\n return null;\n}\n```\n\nElsewhere in browser code, call `getTailglow().track(...)`. Page views follow client-side navigation on their own.\n\n### One instance per page\n\nEvery instance installs its own collectors, so two instances count every page view, click and error twice. Create it once, as above, never in a component body or an effect that can run again. Do not destroy it in an effect's cleanup either: React's Strict Mode runs the cleanup and the effect a second time in development, and the module would keep a destroyed instance.\n\n### Serverless functions\n\n`@tailglow/core` sends records in batches every 30 seconds by default. A serverless function can be frozen as soon as it returns, with records still queued. In a Node or Bun function, such as AWS Lambda, create the instance outside the handler, so warm invocations share it, and flush before returning:\n\n```javascript\nimport { TailglowCore } from \"@tailglow/core\";\n\nconst tg = new TailglowCore({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\nexport async function handler(event) {\n tg.track(\"order_placed\", { order_id: event.order_id });\n await tg.flush();\n return { statusCode: 200 };\n}\n```\n\nWhere the platform offers `waitUntil`, pass it `tg.flush()` instead, so the response is not held up. A script that exits on its own should end with `await tg.destroy()`: until then, the SDK's send timer keeps the process running.\n\nCloudflare Workers allow no timers or random values at module scope, and the SDK starts both when it is created. Create it inside the handler there, and hand its shutdown to `waitUntil`:\n\n```javascript\nimport { TailglowCore } from \"@tailglow/core\";\n\nexport default {\n async fetch(request, env, ctx) {\n const tg = new TailglowCore({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n });\n tg.track(\"api_request\", { path: new URL(request.url).pathname });\n ctx.waitUntil(tg.destroy());\n return new Response(\"ok\");\n }\n};\n```\n\n### Fitting into an existing app\n\n- **Sign-in.** Call `identify()` where the app learns who is signed in, and again on each page load of a signed-in session, because the browser SDK keeps no identity between loads. On sign-out or an account switch, `await resetTailglow()` so the next `getTailglow()` starts a fresh instance; [Identity changes](#identity-changes-logout--login--org-switch) explains why there is no reset in place.\n- **Existing error reporting.** With `autoErrors` on, Tailglow and any other error reporter both receive every uncaught error. Keep both, or set `autoErrors: false` and send Tailglow only the errors you choose.\n\n## Identity model\n\nBrowser 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.\n\n| Layer | Source |\n| ------------ | -------------------------------------------------------------------------------------------------- |\n| `session_id` | In-memory, regenerated after `sessionTimeout` of inactivity |\n| `user_id` | `identify(user_id)`. Same-session records can be backfilled until their request body is serialized |\n| `device_id` | `setDeviceId(id)`. Opt-in, customer-supplied (mobile/desktop) |\n\nSticky sampling resolves in cascade: `user_id` → `device_id` → `session_id`. Without an identifier above session, sampling resets per session (the privacy-preserving default).\n\n## Public API\n\n### Collections model\n\nThe SDK manages three collections (configurable; defaults shown):\n\n| Collection | What lands here |\n| ---------- | --------------------------------------------------------------------------------------------------------------- |\n| `events` | Every `track()` call and every auto-collected event (page views, clicks, vitals, device snapshots, navigation). |\n| `errors` | `captureException`, `captureMessage`, and `console.error`. |\n| `logs` | Non-error console levels selected by `autoConsole`. By default only `console.warn` emits here. |\n\nRecords 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.\n\n```javascript\ntg.track(\"signup\", { plan: \"pro\" });\n// → POST ?collection=events with { type: \"signup\", plan: \"pro\", session_id: ... }\n\ntg.track(\"purchase\", { amount: 99 });\n// → POST ?collection=events with { type: \"purchase\", amount: 99, session_id: ... }\n```\n\nOverride 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()`:\n\n```javascript\ntg.track({ type: \"audit_event\", collection: \"audit_log\", actor: \"user_42\" });\n// → POST ?collection=audit_log with { type: \"audit_event\", actor: \"user_42\", ... }\n```\n\nThe 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.\n\nConfigure the destinations at construction time:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n collections: {\n events: \"events\", // default; override to namespace per app on a shared source\n errors: \"errors\",\n logs: \"logs\"\n }\n});\n```\n\nSet all three to the same slug to merge everything into one timeline collection:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n collections: { events: \"timeline\", errors: \"timeline\", logs: \"timeline\" }\n});\n```\n\n### Sending events\n\n`track(name, props?)` (string form)\nThe `name` parameter is the **event type**, stamped as the `type` field on the record. Routes to the configured `collections.events` destination (default: `events`).\n\n`track({ type, collection?, ...props })` (object form)\nUse 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.\n\n```javascript\ntg.track(\"signup\", { plan: \"pro\" });\ntg.track(\"purchase\", { amount: 99, currency: \"USD\" });\n```\n\nCustomer-supplied `props.type` wins on collision: pass `type: \"...\"` explicitly in props to override the SDK's stamp.\n\n### Errors and exceptions\n\n`captureException(err, props?)`\nCapture 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.\n\n```javascript\ntry {\n riskyOperation();\n} catch (err) {\n tg.captureException(err, { user_step: \"checkout\" });\n}\n```\n\n`level` defaults to `\"error\"`. Override via props:\n\n```javascript\ntg.captureException(err, { level: \"fatal\", user_step: \"checkout\" });\n```\n\n`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:\n\n```javascript\ntg.captureException(err, { fingerprint: \"payment-flow\" });\n```\n\n`captureMessage(message, props?)`\nCapture 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\"`.\n\n```javascript\ntg.captureMessage(\"payment validator returned null\", { level: \"warning\" });\n```\n\n`addBreadcrumb(entry)`\nPush 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.\n\n```javascript\ntg.addBreadcrumb({\n category: \"navigation\",\n message: \"/home → /checkout\",\n timestamp: Date.now()\n});\n```\n\n#### Auto-captured errors and rejections\n\nWhen `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\"`).\n\n#### Auto-captured console activity\n\nWhen `autoConsole` uses its default value of `[\"error\", \"warn\"]`, all supported `console.*` calls are wrapped for breadcrumbs, but only warnings and errors emit records:\n\n| Console method | Default effect | Record destination | `type` |\n| --------------------------- | ------------------------------------------------ | ------------------- | ---------------- |\n| `console.log` | breadcrumb only | none | n/a |\n| `console.warn` | record + breadcrumb | `logs` collection | `\"warn\"` |\n| `console.info` | breadcrumb only | none | n/a |\n| `console.debug` | breadcrumb only | none | n/a |\n| `console.error(err: Error)` | `captureException` + breadcrumb | `errors` collection | error class name |\n| `console.error(string)` | `captureMessage` (level: `\"error\"`) + breadcrumb | `errors` collection | `\"Message\"` |\n\nThe 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.\n\nWhen a non-error level is included in `autoConsole`, its record follows these argument-shape rules:\n\n```javascript\nconsole.log(\"user clicked save\");\n// → { type: \"log\", message: \"user clicked save\" }\n\nconsole.log({ user_id: 42, action: \"save\" });\n// → { type: \"log\", user_id: 42, action: \"save\", message: \"{...}\" } (object flattened)\n\nconsole.log(\"user clicked\", { id: 42, name: \"Alice\" });\n// → { type: \"log\", message: \"user clicked {...}\", id: 42, name: \"Alice\" }\n```\n\n#### Burst protection\n\nTo 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.\n\nConfigurable via:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n errorBurst: { threshold: 10, window_ms: 1000, cooldown_ms: 120_000 }\n});\n```\n\nThe fingerprint counter map is LRU-evicted at 1000 entries to bound memory in long-running sessions.\n\nUnderneath 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.\n\n#### `errors` collection record shape\n\n```json\n{\n \"type\": \"TypeError\",\n \"message\": \"Cannot read 'x' of null\",\n \"stack_raw\": \"TypeError: Cannot...\",\n \"frames\": [\n {\n \"filename\": \"https://cdn.example.com/app.min.js\",\n \"function\": \"calculateTotal\",\n \"lineno\": 1,\n \"colno\": 48201,\n \"in_app\": true\n }\n ],\n \"fingerprint\": \"9c3f8a1b\",\n \"mechanism\": { \"type\": \"uncaught\", \"handled\": false },\n \"level\": \"error\",\n \"cause\": { \"type\": \"Error\", \"message\": \"...\", \"frames\": [] },\n \"breadcrumbs\": [\n { \"category\": \"console\", \"level\": \"log\", \"message\": \"...\", \"timestamp\": 1700000000000 }\n ],\n \"sdk\": { \"name\": \"tailglow.browser\", \"version\": \"x.y.z\" }\n}\n```\n\n#### `logs` collection record shape\n\n```json\n{\n \"type\": \"log\",\n \"message\": \"user clicked save\",\n \"session_id\": \"sess_...\",\n \"event_id\": \"...\",\n \"event_time\": \"...\",\n \"sdk\": { \"name\": \"tailglow.browser\", \"version\": \"x.y.z\" }\n}\n```\n\n### Identity & context\n\n`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.\n\n`unidentify()`: clear the current user ID. Equivalent to `identify(\"\")` but explicit.\n\n`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.\n\n`rotateSession()`: force a new session ID at an explicit boundary (workflow finished, end-of-flow). Returns the new ID.\n\n`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.\n\n`clearContext()`: drop everything previously added via `setContext`.\n\n```javascript\ntg.identify(\"usr_123\");\ntg.setContext({\n release: \"v2.1.0\",\n environment: \"production\",\n email: \"alice@example.com\",\n plan: \"pro\",\n region: \"us-west\",\n subscription: { tier: \"pro\", seats: 12 }\n});\n```\n\nTailglow 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.\n\n#### Sticky-sample identity transition\n\nSampling 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.\n\n#### Identity changes (logout / login / org switch)\n\nThere is no `reset()` method. The pattern is flush, destroy, reconstruct:\n\n```javascript\nawait tg.flush(); // attempt to drain pending records under the old identity\nawait tg.destroy(); // remove listeners, stop the queue interval\ntg = new Tailglow({ ...sdkConfig });\n```\n\n### Opt-out and privacy\n\nThe 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).\n\nTo let a user turn analytics off:\n\n`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.\n`optIn()` / `isOptedOut()`: resume tracking / check status.\n\n```javascript\ntg.optOut(); // drop buffered records and stop accepting new records in the core\ntg.optIn(); // resume\n```\n\nFor 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.\n\n```javascript\nif (userAccepted) tg = new Tailglow({ ...config });\n// later, to revoke:\ntg.optOut();\nawait tg.destroy();\n```\n\n### Lifecycle\n\n`flush()`: async; attempt to flush all queued records and wait for the current flush cycle. Retryable failures that exhaust their transport attempts remain queued.\n`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.\n`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.\n\n#### Exit-time delivery\n\nBrowser 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.\n\n## Configuration\n\nAll options have sensible defaults. Only `url` is required, and it may already carry the key.\n\n### Core options (all packages)\n\n| Option | Type | Default | Description |\n| -------------------------- | --------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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=`. |\n| `key` | `string` | optional | Ingest key (starts with `tg_ingest_`). Omit it when `url` carries one; supplying it overrides the carried key. |\n| `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`. |\n| `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. |\n| `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. |\n| `flushInterval` | `number` | `30000` | Auto-flush interval in ms |\n| `flushSize` | `number` | `100` | Auto-flush at this record count |\n| `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. |\n| `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. |\n| `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. |\n| `sessionTimeout` | `number` | `1800000` | Session timeout (30 min) |\n| `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`. |\n| `maxRetries` | `number` | `3` | Retry attempts after network errors and 5xx, 408, or 429 responses. Honors `Retry-After`. |\n| `retryDelay` | `number` | `1000` | Initial retry delay (doubles each attempt) |\n| `sampleRate` | `number` | `1.0` | Sticky sampling rate (0 to 1) |\n| `deviceId` | `string` | - | Customer-supplied device ID for this SDK instance. The caller owns persistence across launches. |\n| `userId` | `string` | - | Initial user ID |\n| `storageAdapter` | `StorageAdapter` | - | Async storage adapter for non-browser persistence |\n| `onBeforeSend` | `(record) => record \\| null` | - | Filter/enrich records, return null to drop |\n| `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. |\n| `redact.enabled` | `boolean` | `true` | Master redaction switch |\n| `redact.redactEmails` | `boolean` | `true` | Redact email-shaped strings |\n| `redact.redactFields` | `string[]` | - | Drop these top-level fields |\n| `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. |\n| `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. |\n| `errorBurst` | `object` | see above | Per-fingerprint client-side rate limit. Keys: `threshold` (default 10), `window_ms` (1000), `cooldown_ms` (120000), `max_entries` (1000). |\n| `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. |\n| `breadcrumbBuffer` | `number` | `100` | Ring buffer size for breadcrumbs. |\n| `debug` | `boolean` | `false` | Console logging |\n\n**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)`.\n\n### Browser-only options (`@tailglow/browser`)\n\n| Option | Type | Default | Description |\n| ----------------------- | ------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `autoPageViews` | `boolean` | `true` | SPA navigation tracking |\n| `autoSections` | `boolean` | `true` | `data-telemetry` element visibility + clicks |\n| `autoErrors` | `boolean` | `true` | Uncaught errors and unhandled rejections |\n| `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. |\n| `autoDevice` | `boolean` | `true` | Send device info on init |\n| `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. |\n| `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. |\n| `spaMode` | `\"auto\" \\| \"history\" \\| \"hash\" \\| \"off\"` | `\"auto\"` | SPA navigation strategy |\n| `routeContext` | `() => object \\| null` | - | Called at every page_view emission; returned fields (route template, params) merge into the record. Auto-collected fields win on collision. It runs the moment the URL changes, so read `location`, not router state that updates later (SvelteKit's `page`). |\n| `sectionAttribute` | `string` | `\"data-telemetry\"` | Attribute name for section tracking |\n| `intersectionThreshold` | `number` | `0.5` | Share of a section that must be visible, or of the screen it must fill, to count as on screen |\n| `honorDnt` | `boolean` | `true` | Honor `navigator.doNotTrack` |\n| `honorGpc` | `boolean` | `true` | Honor `navigator.globalPrivacyControl` |\n| `excludeLocalhost` | `boolean` | `true` | Skip tracking on localhost / file:// |\n| `respectDevOptOut` | `boolean` | `false` | Read `tglow_ignore` localStorage flag |\n\n## Privacy defaults\n\nThe browser SDK is designed to operate without persistent client storage:\n\n| Default | Behavior |\n| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Honor DNT | If `navigator.doNotTrack === \"1\"`, the SDK is fully disabled. |\n| Honor GPC | If `navigator.globalPrivacyControl === true`, the SDK is fully disabled. |\n| Skip localhost | The SDK does not track on `localhost`, `127.0.0.1`, `0.0.0.0`, or `file://`. |\n| No browser storage | The default browser path does **not** read or write cookies, localStorage, sessionStorage, or IndexedDB. |\n| 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. |\n\nThe `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.\n\n### Console capture\n\nDefault `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.\n\nTo broaden capture, list more levels. To disable entirely, pass `[]`.\n\n```javascript\n// Default: errors and warnings as records, all levels in breadcrumbs\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\"\n});\n\n// Errors only on the wire, breadcrumbs still capture from log/warn/info/debug\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n autoConsole: [\"error\"]\n});\n\n// Capture everything (data-lake mode)\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n autoConsole: [\"log\", \"warn\", \"info\", \"debug\", \"error\"]\n});\n\n// Disable entirely: no wrapping, no breadcrumbs from console\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n autoConsole: []\n});\n```\n\nRouting per level when emitted:\n\n- `log` / `warn` / `info` / `debug` → configured `logs` collection with `type: \"<level>\"`\n- `error(Error)` → `captureException` → configured `errors` collection with `type` = error class name\n- `error(string)` → `captureMessage` (level: `\"error\"`) → configured `errors` collection with `type: \"Message\"`\n\n**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.\n\n**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.\n\n## Testing on localhost\n\nBy 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`.\n\n### ESM\n\n```javascript\nimport { Tailglow } from \"@tailglow/browser\";\n\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_your_key\",\n excludeLocalhost: false, // enable on localhost\n context: { environment: \"development\" }, // tag dev events so they're filterable\n debug: true // log SDK activity to the console\n});\n```\n\n### Drop-in script\n\n```html\n<script\n defer\n src=\"https://cdn.tailglow.io/tglow.js\"\n data-key=\"tg_ingest_your_key\"\n data-url=\"https://{region}.ingest.tailglow.io/{project_id}\"\n data-exclude-localhost=\"false\"\n data-environment=\"development\"\n data-debug=\"true\"\n></script>\n```\n\n(`data-release`, `data-environment`, and `data-dist` flow into `context` automatically. For richer context, call `tglow(\"setContext\", {...})` after the script loads.)\n\n### Recommended: tag dev events with a distinct `environment`\n\nWhen 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.\n\nA 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:\n\n```javascript\nconst environment = import.meta.env.MODE;\n\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n excludeLocalhost: import.meta.env.PROD,\n context: { environment }\n});\n```\n\n### Want to disable in dev too? Use `tglow_ignore`\n\nThe `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`:\n\n```javascript\nnew Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n respectDevOptOut: true\n});\n```\n\nThen in the browser console: `localStorage.setItem(\"tglow_ignore\", \"true\")`. SDK will be disabled on next load.\n\n## Auto-collected events (browser)\n\nBrowser 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 or metrics to distinguish `page_view`, `click`, `vital`, and other record kinds.\n\n### Page views: `type = \"page_view\"`\n\n```json\n{\n \"type\": \"page_view\",\n \"from_path\": \"/\",\n \"to_path\": \"/pricing\",\n \"duration_ms\": 4500,\n \"nav_type\": \"push\",\n \"url\": \"https://example.com/pricing\"\n}\n```\n\nThe URL field uses `<link rel=\"canonical\">` if present, otherwise `window.location.href`.\n\n### Section visibility & clicks\n\n`type = \"section_view\"` | `\"click\"` | `\"outbound_click\"` | `\"scroll_depth\"`.\n\n```html\n<section data-telemetry=\"hero\">...</section>\n```\n\nA `section_view` is sent when the tab is hidden and when the page changes. In the default `spaMode`, a change to the URL's query or hash also counts as a page change.\n\n- `duration_ms` is how long the section was on screen since its previous `section_view`, so sum it. A section is on screen while half of it is visible or it fills half the window (`intersectionThreshold`).\n- `seen_percent` is the share of the section's height that has been visible at all since the page change, and `has_reached_end` says its bottom edge has been. Both only grow until the next page change, so the last record before it holds the final value.\n- Anything the browser hides, such as the part of a section a scrolling or `overflow: hidden` container cuts off, does not count as visible.\n- Elements that share a `data-telemetry` value are one section: on screen while any of them is, with `seen_percent` from the one seen most and `has_reached_end` set once any of them has shown its bottom edge.\n- A section removed from the page, or whose `data-telemetry` value changes, stops counting at once and keeps what it showed. A new value starts a section of its own.\n\n```json\n// section_view\n{ \"type\": \"section_view\", \"section\": \"hero\", \"path\": \"/\", \"duration_ms\": 8200, \"seen_percent\": 100, \"has_reached_end\": true }\n// click\n{ \"type\": \"click\", \"section\": \"pricing\", \"path\": \"/\", \"tag\": \"button\", \"text\": \"Sign up\" }\n// outbound_click\n{ \"type\": \"outbound_click\", \"href\": \"https://github.com/...\", \"text\": \"View on GitHub\", \"path\": \"/\" }\n// scroll_depth\n{ \"type\": \"scroll_depth\", \"depth_percent\": 85, \"path\": \"/\" }\n```\n\n### Errors: `errors` collection\n\n```json\n{\n \"type\": \"TypeError\",\n \"message\": \"Cannot read 'x' of null\",\n \"stack_raw\": \"TypeError: Cannot read 'x' of null\\n at ...\",\n \"frames\": [\n {\n \"filename\": \"https://cdn.example.com/app.min.js\",\n \"function\": \"calculateTotal\",\n \"lineno\": 1,\n \"colno\": 48201,\n \"in_app\": true\n }\n ],\n \"fingerprint\": \"9c3f8a1b\",\n \"mechanism\": { \"type\": \"uncaught\", \"handled\": false },\n \"level\": \"error\",\n \"breadcrumbs\": [\n { \"category\": \"console\", \"level\": \"log\", \"message\": \"...\", \"timestamp\": 1700000000000 }\n ]\n}\n```\n\n`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\"`).\n\n### Performance signals: `type = \"vital\"`\n\n```json\n{ \"type\": \"vital\", \"vital_name\": \"LCP\", \"vital_value\": 1200, \"path\": \"/\" }\n{ \"type\": \"vital\", \"vital_name\": \"CLS\", \"vital_value\": 0.05, \"path\": \"/\" }\n```\n\nThese 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.\n\n### Device: `type = \"device\"`\n\nSent once on init. Includes browser, OS, device type, screen, viewport, language, timezone, connection (effectiveType / downlink / rtt), referrer source, and UTM parameters.\n\n### Session summary: `type = \"session_summary\"`\n\n```json\n{\n \"type\": \"session_summary\",\n \"session_id\": \"sess_Abc123\",\n \"engaged_ms\": 145200,\n \"pages_viewed\": 7,\n \"entry_path\": \"/\",\n \"exit_path\": \"/pricing\",\n \"reason\": \"hide\"\n}\n```\n\nEmitted 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`.\n\n## Record metadata\n\nThe 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.\n\n| Field | Description |\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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. |\n| `session_id` | SDK's session ID, renews after `sessionTimeout`. Customer can override. |\n| `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). |\n| `user_id` | Included after `identify()` or `userId` is set. Customer can override per-record (B2B/CRM use cases). |\n| `device_id` | Included after `setDeviceId()` or `deviceId` is set. Customer can override. |\n| `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. |\n| `sdk` | SDK package name and version. Included unless the customer provides an `sdk` field. |\n\nPlus 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).\n\nThe 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.\n\n### Reserved field names\n\nThese 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).\n\n```\nevent_id, event_time, session_id, user_id, device_id, type, sdk\n```\n\n## Transport\n\nAll 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.\n\nFailed 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.\n\nOn 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.\n\n## Hooks\n\n`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.\n\n```javascript\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n onBeforeSend: (record) => {\n if (\n record.__tg_collection === \"errors\" &&\n record.frames?.some((f) => f.filename?.includes(\"third-party\"))\n ) {\n return null;\n }\n record.app_build = \"abc123\";\n return record;\n }\n});\n```\n\n## Pipeline order\n\nThe common path for records accepted by `track()` or `capture()` is:\n\n```\nsession touch → sticky sampling → rateLimit gate → stamp → redact → maxRecordBytes check → onBeforeSend → queue → flush → transport\n```\n\n- 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.\n- 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.\n- `onBeforeSend` sees the post-redaction record. Set `redact.enabled: false` if your hook needs raw payloads.\n- `maxRecordBytes` runs before `onBeforeSend`; oversized records never reach the hook.\n- `onTransportError` fires for immediate non-retryable 4xx rejections and unexpected exceptions in the send path. Exhausted retryable failures are requeued without calling the hook.\n\n## Lifecycle without auto-collectors\n\nIf 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:\n\n```javascript\nconst tg = new Tailglow({\n url: \"https://{region}.ingest.tailglow.io/{project_id}\",\n key: \"tg_ingest_...\",\n autoPageViews: false,\n autoSections: false,\n autoErrors: false,\n autoVitals: false,\n autoDevice: false,\n autoConsole: [],\n excludeLocalhost: false\n});\n// Visibility/beforeunload flush via sendBeacon stays active.\n```\n\n`excludeLocalhost: false` is required for Electron renderer pages loaded from `file://`, which the browser package excludes by default.\n\n## Typed event schemas\n\nAugment `TailglowEventTypes` on the package your app imports: `@tailglow/browser`, `@tailglow/react-native` or `@tailglow/core`. All three export the same interface, so one declaration types every `track()` call. Declare it on the package you installed: strict installs (pnpm, Yarn Plug'n'Play, Bun workspaces) only let your app resolve packages it installed itself. Event types not in the schema fall through with `Record<string, unknown>`.\n\nThe augmented keys describe the `type` field of records sent to the configured `events` collection, not a separate collection per key.\n\n```ts\ndeclare module \"@tailglow/browser\" {\n interface TailglowEventTypes {\n signup: { plan: \"free\" | \"pro\" };\n purchase: { amount: number; currency: string };\n }\n}\n\ntg.track(\"signup\", { plan: \"pro\" }); // ✓ typed\ntg.track(\"signup\", { plan: \"wrong\" }); // ✗ TS error\ntg.track(\"anything_else\", { whatever: true }); // ✓ falls back to Record<string, unknown>\n```\n\n## Platform support\n\n| Platform | Auto-collection | Lifecycle | Storage |\n| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |\n| 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 |\n| 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 |\n| 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 |\n| Electron renderer | Use `@tailglow/browser`; set `excludeLocalhost: false` for `file://` | Same best-effort lifecycle as browser | In-memory by default |\n| 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 |\n",
|
|
47
47
|
"guides/teams": "# Teams\n\n## Overview\n\nA team is the topmost level of organization in Tailglow.\nYou can have multiple projects within a team, and you can add different users to each team.\n\n## Creating a Team\n\nA new account starts without a team. After profile setup, the **Set up your team & project** onboarding form creates the first team and project when you click **Create Team & Project**. To create another team later, open the team switcher in the top toolbar, select **Create New Team**, and complete the same form.\n\n## Team Settings\n\nTo access your team settings, click your team name in the top toolbar. This opens the team overview, where you can view all projects. Select the settings icon next to **Add New**.\n\n### General\n\nIn the **General** settings, you can:\n\n- Change your team name.\n- View your team's status.\n- Update your team's logo.\n- Delete your team.\n\nIf you delete your team, it will not be removed immediately. Instead, every member loses access to\nthe team, every ingest key is revoked, every API key is deleted, the team's servers are scaled to\nzero, and permanent removal is scheduled for forty days later. Members stay signed in to their other\nteams. Nothing is billed while a team is archived. Your projects and their data are kept for the\nwhole window. If you need the team deleted immediately, contact support.\n\nOnly an Owner can do this. In the **Delete Team** card, click **Schedule For Deletion**, then\nconfirm by typing your team's name and the phrase **Schedule To Delete**. Both fields ignore case\nand surrounding spaces. Every member loses access as soon as you confirm, so the confirmation is\ndeliberately heavier than the one on other resources.\n\nTo restore an archived team, an Owner selects it from the team menu, or signs in when it is their\nonly team, and clicks **Restore Team** on the **Recover** page. Selecting the team on its own does\nnot restore it. Restoring does not reactivate the revoked ingest keys or bring back the servers, so\nadd active ingest keys to your sources and servers to your projects before sending data again.\n\n### Statuses\n\nThere are four statuses for your team:\n\n- **Active**: Your team is active and can be accessed by all team members.\n- **Delinquent**: Tailglow emails your billing contact after a failed payment. A non-Enterprise team can become delinquent if an invoice becomes uncollectible or remains unpaid into a later billing cycle. Ingestion is paused until the issue is resolved.\n- **Restricted**: Your team is read-only and ingest keys are disabled. Billing updates remain available so an Owner can address billing. An Owner cannot delete a restricted team through the application and must contact support.\n- **Blocked**: Your account has been blocked, typically due to a violation of our terms of service. You will no longer be able to access the account.\n\n### Users\n\nIn the **Users** settings, you can manage everyone who has access to your team. All users will have access to all projects within the team.\n\n### Security\n\nIn the **Security** settings, you can:\n\n- **Require MFA**: Turn on **Require Multi-factor authentication** to require every team member to enable MFA before accessing the team. Only the Owner can change this setting, and the Owner must have verified MFA first.\n\n### Billing\n\nIn the **Billing** settings, you can:\n\n- **Billing Email**: Set a billing-specific email address for invoices and payment notifications.\n- **Billing Plan**: View and manage your current billing plan.\n- **Billing Address**: Set your team's billing address for invoices.\n\n### Roles\n\nIn the **Roles** settings, you can create roles for your team members. By default, there are two roles: **Owner** and **Member**. The built-in **Owner** role cannot be created, updated, or deleted, and there must always be at least one owner.\n\nThe **Quick fill** actions are **Common**, **Full access**, and **Clear**. You can adjust the\npermissions before saving. **Clear** removes optional access while retaining the project read access\nrequired for team members.\n",
|
|
48
48
|
"guides/users": "# Users\n\n## Overview\n\nUsers are individuals who have access to a specific team within Tailglow. Each user has one role in that team, which determines their permissions. By managing users effectively, you can ensure proper access control for your projects.\n\n## Adding Users\n\nTo add a new user to your team:\n\n1. Click your team name in the top toolbar, then click **Team Settings** on the team overview.\n2. Go to the **Users** tab.\n3. Click the plus button in the **Users** card.\n4. Enter the user's name, email address, and assign a role.\n5. Click **Add User**. Tailglow adds the user to the team immediately and emails them. Someone new\n to Tailglow gets a button that opens the sign-in page with their email filled in. Someone who\n already uses Tailglow gets a link to the app.\n\n## Managing Users\n\nIn the **Users** tab of your team settings, you can:\n\n- **View all users**: See a list of all users currently in your team.\n- **Review activity**: See `Recently active` when a user used Tailglow within the last five minutes,\n or the relative time of their last activity otherwise. Activity is global and may have occurred\n in another team.\n- **Edit roles**: Change the role of a user by selecting from the predefined roles or custom roles you've created. If you change a user's role, they will be notified via email and signed out.\n- **Remove users**: Click the user's edit button, then hold the trash button labeled **Hold to remove user** until the action confirms.\n\n## Updating Your Profile\n\nTo update your own profile, click on your image in the top right corner and select **Profile**. Here, you can update your name, preferences, and profile image.\n\n## Roles and Permissions\n\nUsers' access and capabilities within the team are determined by their assigned roles. Each role has specific permissions. By default, Tailglow includes two roles:\n\n- **Owner**: Full access to all features, including team and project management.\n- **Member**: Can view data but has limited permissions for modifying settings or projects.\n\nYou can also create custom roles under the **Roles** tab in the settings to provide more tailored permissions.\n\nUse **Common**, **Full access**, or **Clear** as a starting point when creating a custom role, then\nadjust its permissions before saving. See [Permissions](/guides/permissions) for details.\n\n## Notification Preferences\n\nEach user can configure their notification preferences from **Profile** > **Notifications**:\n\n- **Auto-subscribe to new monitors**: Automatically subscribe to notifications for newly created monitors.\n- **Product Updates**: Choose whether to receive noteworthy product and feature announcements.\n",
|
|
49
49
|
"guides/projects": "# Projects\n\n## Overview\n\nA project in Tailglow represents a container for your sources, metrics, and pages. Projects are used to organize data for specific purposes, such as a product, application, or environment. Each project has its own settings, while a member's team role scopes govern access across the team's projects.\n\n## Creating a Project\n\nTo create a new project:\n\n1. Navigate to your **Team Overview** by selecting your team name in the top toolbar.\n2. Click the **\"Add New\"** button in the top-right corner.\n3. Enter the project name and click \"Create Project\".\n4. Once created, the project will appear in your **Team Overview**.\n\n## Project Settings\n\nTo access a project's settings:\n\n1. Select a project from the sidebar.\n2. Navigate to the **Settings** tab.\n\n### General Settings\n\nIn the general settings, you can:\n\n- Rename your project.\n- Update the project start date, which Tailglow uses as the starting point for displayed charts.\n- Set the late data window, which controls how far back an event with an overridden timestamp can\n reach and still enter charts automatically. See\n [Late data](/guides/metrics#late-data).\n- Delete the project.\n **Note**: Deleting a project schedules it for permanent removal seven days later. You can\n restore the project from the settings page any time before that date. Ingest keeps running for the\n whole window, so a restored project carries on with nothing missing.\n\n### Sources\n\nSources own ingest configuration and ingest keys. Collections under a source own the records, files, and schemas. You can manage sources and their ingest keys from **Settings** > **Sources** in your project.\n\n### Views\n\nIn this section, you can:\n\n- Create, edit, and delete views that use TGL to transform collection or view inputs and join data when configured.\n\n## Sending Data to a Project\n\nEach ingest key authenticates a source. Tailglow routes an accepted request to a collection under that source using the request's collection selection or the source's default routing. To send data:\n\n- Send data to your project's ingest endpoint (found in your source settings), e.g. `https://us11.ingest.tailglow.io/prj_xxx`.\n- Include a source's ingest key in the `key` query parameter, or use the `Authorization` header.\n\nRefer to the [Ingest Key](/guides/ingest-key) documentation for detailed steps on sending data.\n\n## Deleting a Project\n\nIf a project is no longer needed and its data is no longer relevant, you can delete it. Deleting a project will:\n\n- Schedule permanent removal of all data, settings, and configurations for seven days later.\n- Allow you to restore the project any time before that removal date.\n- Offer a **Delete Permanently** option in project settings, if you would rather not wait out the\n window. That one cannot be undone.\n- Keep accepting data until the removal date, so restoring the project loses nothing.\n\nTo schedule a project for deletion:\n\n1. Open the project's **Settings** tab and select **General**.\n2. In the **Delete Project** card, press and hold **Hold to Schedule Deletion** until it completes.\n\nThe project settings page then shows a red banner counting down to the removal date. Click\n**Restore Project** there at any point before it to cancel.\n\nTo remove the project immediately instead:\n\n1. Click **Delete Permanently** in that banner.\n2. Type the project name, then type **Delete permanently**.\n3. Click **Delete permanently**. This cannot be undone.\n\n## Best Practices\n\n- **Use separate projects for different environments**: For example, create distinct projects for production, staging, and development environments.\n- **Organize projects by product or feature**: If you have multiple products, consider creating one project per product for better data separation and management.\n- **Monitor project activity**: Regularly check activity logs and metrics to ensure the project is running as expected.\n",
|