JavaScript SDK

JavaScript SDK

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

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

New here? The Quickstart gets a web app reporting in about two minutes. This page is the full reference.

Runtimes

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

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

Installation

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

Drop-in <script> (no build step)

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

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

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

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

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

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

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

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

Declarative event tracking

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

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

Initializing

The full browser walkthrough is in the Quickstart. Below is the minimal init per runtime.

Browser

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

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

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

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

Node / Bun (via @tailglow/core)

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

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

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

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

React Native (@tailglow/react-native)

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

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

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

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

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

Identity model

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

LayerSource
session_idIn-memory, regenerated after sessionTimeout of inactivity
user_ididentify(user_id). Same-session records can be backfilled until their request body is serialized
device_idsetDeviceId(id). Opt-in, customer-supplied (mobile/desktop)

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

Public API

Collections model

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

CollectionWhat lands here
eventsEvery track() call and every auto-collected event (page views, clicks, vitals, device snapshots, navigation).
errorscaptureException, captureMessage, and console.error.
logsNon-error console levels selected by autoConsole. By default only console.warn emits here.

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

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

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

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

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

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

Configure the destinations at construction time:

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

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

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

Sending events

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

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

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

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

Errors and exceptions

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

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

level defaults to "error". Override via props:

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

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

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

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

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

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

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

Auto-captured errors and rejections

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

Auto-captured console activity

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

Console methodDefault effectRecord destinationtype
console.logbreadcrumb onlynonen/a
console.warnrecord + breadcrumblogs collection"warn"
console.infobreadcrumb onlynonen/a
console.debugbreadcrumb onlynonen/a
console.error(err: Error)captureException + breadcrumberrors collectionerror class name
console.error(string)captureMessage (level: "error") + breadcrumberrors collection"Message"

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

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

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

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

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

Burst protection

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

Configurable via:

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

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

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

errors collection record shape

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

logs collection record shape

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

Identity & context

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

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

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

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

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

clearContext(): drop everything previously added via setContext.

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

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

Sticky-sample identity transition

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

Identity changes (logout / login / org switch)

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

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

Opt-out and privacy

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

To let a user turn analytics off:

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

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

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

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

Lifecycle

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

Exit-time delivery

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

Configuration

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

Core options (all packages)

OptionTypeDefaultDescription
urlstringrequiredFull 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=.
keystringoptionalIngest key (starts with tg_ingest_). Omit it when url carries one; supplying it overrides the carried key.
enabledbooleantrueEnvironment gate. When false, normal capture, identity, context, lifecycle, and diagnostic methods are inert or return neutral values. No timers, network, queue, or collectors are created. With debug: true, construction logs one disabled diagnostic. Do not call core-only transport or queue escape hatches such as getTransport() or drainQueue() on a disabled TailglowCore.
collections{ events?, errors?, logs? }{ events: "events", errors: "errors", logs: "logs" }Routing destinations. events = track() + event auto-collectors; errors = captureException/captureMessage/uncaught errors/console.error; logs = the other enabled console wrappers. Set all three to the same slug to merge into one timeline collection.
contextRecord<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.
flushIntervalnumber30000Auto-flush interval in ms
flushSizenumber100Auto-flush at this record count
maxBatchBytesnumber15000000Approximate serialized-byte target used to split queued records into outgoing batches. A single record can exceed it when maxRecordBytes is configured higher.
maxQueueSizenumber10000Maximum 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.
maxRecordBytesnumber1000000 (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.
sessionTimeoutnumber1800000Session timeout (30 min)
onSessionRotate(info) => void-Fires after the session ID rotates (timeout or rotateSession()), with a snapshot of the ended session: previous_session_id, reason, started_at, last_activity_at.
maxRetriesnumber3Retry attempts after network errors and 5xx, 408, or 429 responses. Honors Retry-After.
retryDelaynumber1000Initial retry delay (doubles each attempt)
sampleRatenumber1.0Sticky sampling rate (0 to 1)
deviceIdstring-Customer-supplied device ID for this SDK instance. The caller owns persistence across launches.
userIdstring-Initial user ID
storageAdapterStorageAdapter-Async storage adapter for non-browser persistence
onBeforeSend(record) => record \| null-Filter/enrich records, return null to drop
onTransportError(error, batch) => void-Fires for a collection group rejected by a non-retryable 4xx and for unexpected exceptions in the SDK send path, with the affected records. Exhausted retryable network, 408, 429, and 5xx failures are requeued without calling this hook.
redact.enabledbooleantrueMaster redaction switch
redact.redactEmailsbooleantrueRedact email-shaped strings
redact.redactFieldsstring[]-Drop these top-level fields
redact.allowFieldsstring[]-Restrict top-level fields to an allowlist. Internal __tg_* routing plus event_id, event_time, session_id, user_id, device_id, and sdk pass automatically. Include type in the allowlist if you want to preserve it.
redact.stripQueryStringsbooleanfalseStrip 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.
errorBurstobjectsee abovePer-fingerprint client-side rate limit. Keys: threshold (default 10), window_ms (1000), cooldown_ms (120000), max_entries (1000).
rateLimit{ burst?, perMinute? } \| false{ burst: 200, perMinute: 120 }Global token-bucket rate limit across every record type, enforced after sticky sampling and before stamping or queueing. Each admitted record spends one token; when the bucket empties records are dropped and a collapsed tglow_rate_limited self-event reports the count (at most once per 60s). burst must be a finite number at least 1 and perMinute a finite number at least 0 (perMinute: 0 is drain-only); invalid values fall back to defaults with one warning. Pass false to disable.
breadcrumbBuffernumber100Ring buffer size for breadcrumbs.
debugbooleanfalseConsole logging

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

Browser-only options (@tailglow/browser)

OptionTypeDefaultDescription
autoPageViewsbooleantrueSPA navigation tracking
autoSectionsbooleantruedata-telemetry element visibility + clicks
autoErrorsbooleantrueUncaught errors and unhandled rejections
autoVitalsbooleantrueLightweight browser performance signals named LCP, CLS, INP, FCP, and TTFB. These are SDK approximations, not standards-compliant Core Web Vitals calculations.
autoDevicebooleantrueSend device info on init
autoSessionSummarybooleantrueEmit 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.
autoConsoleArray<"log"\|"warn"\|"info"\|"debug"\|"error">["error","warn"]Console levels that emit records on the wire. Levels not listed still feed breadcrumbs. [] disables wrapping entirely.
spaMode"auto" \| "history" \| "hash" \| "off""auto"SPA navigation strategy
routeContext() => object \| null-Called at every page_view emission; returned fields (route template, params) merge into the record. Auto-collected fields win on collision. Use it to stamp a stable router route ID for downstream views and metrics.
sectionAttributestring"data-telemetry"Attribute name for section tracking
intersectionThresholdnumber0.5Visibility ratio for “seen”
honorDntbooleantrueHonor navigator.doNotTrack
honorGpcbooleantrueHonor navigator.globalPrivacyControl
excludeLocalhostbooleantrueSkip tracking on localhost / file://
respectDevOptOutbooleanfalseRead tglow_ignore localStorage flag

Privacy defaults

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

DefaultBehavior
Honor DNTIf navigator.doNotTrack === "1", the SDK is fully disabled.
Honor GPCIf navigator.globalPrivacyControl === true, the SDK is fully disabled.
Skip localhostThe SDK does not track on localhost, 127.0.0.1, 0.0.0.0, or file://.
No browser storageThe default browser path does not read or write cookies, localStorage, sessionStorage, or IndexedDB.
RedactionURL token patterns and email-shaped strings are redacted before send, recursively through at most eight container levels. Strings longer than 4,096 characters are not scanned.

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

Console capture

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

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

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

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

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

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

Routing per level when emitted:

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

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

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

Testing on localhost

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

ESM

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

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

Drop-in script

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

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

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

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

const environment = import.meta.env.MODE;

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

Want to disable in dev too? Use tglow_ignore

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

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

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

Auto-collected events (browser)

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

Page views: type = "page_view"

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

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

Section visibility & clicks

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

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

Errors: errors collection

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

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

Performance signals: type = "vital"

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

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

Device: type = "device"

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

Session summary: type = "session_summary"

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

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

Record metadata

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

FieldDescription
event_idUnique 16-character correlation ID. The client uses it to avoid some repeat sends within one live SDK instance, but the ingest service does not deduplicate by this field. Duplicate records remain possible, and overriding it does not provide server-side idempotency.
session_idSDK’s session ID, renews after sessionTimeout. Customer can override.
event_timeISO 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.
user_idIncluded after identify() or userId is set. Customer can override per-record (B2B/CRM use cases).
device_idIncluded after setDeviceId() or deviceId is set. Customer can override.
typeEvent 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.
sdkSDK package name and version. Included unless the customer provides an sdk field.

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

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

Reserved field names

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

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

Transport

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

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

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

Hooks

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

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

Pipeline order

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

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

Lifecycle without auto-collectors

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

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

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

Typed event schemas

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

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

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

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

Platform support

PlatformAuto-collectionLifecycleStorage
Browser (@tailglow/browser)Page views, tagged sections and clicks, declarative and outbound clicks, JS errors, console breadcrumbs plus error/warn records, performance signals, deviceBest-effort visibilitychange / beforeunload delivery through sendBeaconIn-memory by default
Node / Bun / edge (@tailglow/core)None. Call track() and capture methods explicitlyNo automatic process or request hook. The caller must await lifecycle methods when neededIn-memory unless a storageAdapter is provided
React Native (@tailglow/react-native)JS errors, best-effort rejection hooks, console breadcrumbs plus error/warn records; screen views after attachNavigation(); device when modules are providedInjected AppState starts best-effort persist, then flush, when leaving activeOptional createAsyncStorageAdapter(AsyncStorage), newest 1,000 records
Electron rendererUse @tailglow/browser; set excludeLocalhost: false for file://Same best-effort lifecycle as browserIn-memory by default
Electron mainUse @tailglow/coreCustomer owns the quit lifecycle and must keep the process alive for awaited delivery attemptsIn-memory unless a customer adapter is provided