Auto-Collected & Custom Events

The tracker captures a lot of behavior automatically so most sites don't need to write any custom event code at all. When you do need custom events, the karma() JavaScript API is the only surface.

Common Event Envelope

Every event the tracker emits — auto or custom — ships with the same envelope:

{
  "event_id":   "550e8400-e29b-41d4-a716-446655440000",
  "event_name": "page_view",
  "event_kind": "auto",
  "event_ts":   "2026-05-22T03:45:01.234Z",
  "visitor_id": "v_8f2c1a09",
  "session_id": "s_4d77b1e2",
  "user_id":    null,
  "page_url":   "https://customer.example/pricing?utm_source=twitter",
  "page_path":  "/pricing",
  "page_title": "Pricing - Customer",
  "referrer":   "https://t.co/abc123",
  "utm":        { "source": "twitter", "campaign": "launch" },
  "properties": { /* event-specific */ },
  "consent":    { "analytics": null, "marketing": null, "dnt": false, "gpc": false }
}

Auto-Collected Events

page_view

Fired on initial load and on every SPA navigation (pushState, replaceState, popstate). Carries the standard envelope only — no extra properties.

For SPA navigations, the tracker flushes any pending web-vitals events with page_url / page_path overrides set to the previous page before emitting the new page_view. This makes per-page LCP / CLS / INP correct even on long-lived single-page apps.

click

Fired on every mousedown (not click — captures right-click and middle-click too). Properties:

Property Source
tag Lowercased tagName of the clicked element.
id Element id attribute, if any.
class_list First 4 classes joined with space, max 256 chars.
text Trimmed innerText, max 256 chars.
href Anchor / form-action URL, if present.
selector_path Hand-rolled CSS selector — up to 5 levels of tag.class:nth-of-type(N). Used by the AI element picker and by Tag Manager triggers.
is_outbound true if href host differs from location.host.

Outbound clicks also fire a synthetic outbound_click event with the same properties so funnels can target them without a custom filter.

scroll_depth

Fired at the first time the visitor crosses 25%, 50%, 75%, or 100% of the document height. Each threshold fires at most once per page. Property: { depth_pct: 25 | 50 | 75 | 100 }.

form_submit

Fired on submit of any <form> on the page (capturing phase, so it sees Submit even when the form's own handler preventDefault()s it). Privacy-safe by design:

Property Source
form_id Form id attribute.
form_name Form name attribute.
action Form action URL.
field_names Array of input name attributes — values are never collected.
has_password true if any <input type="password"> is present.
field_count Total number of inputs/selects/textareas.

Values are intentionally never collected — only structure. To collect form outcomes, fire a custom event in your own onsubmit handler.

web_vitals

The tracker uses PerformanceObserver to capture Core Web Vitals. The first batch is reported on pagehide / visibilitychange: hidden; subsequent batches are also flushed on SPA navigation with page_url / page_path overrides so per-page numbers are correct.

Property Source
metric 'lcp' | 'cls' | 'inp'
value LCP / INP in milliseconds; CLS as unitless float.
element_selector Selector of the element that drove the metric (LCP target, layout shift source, INP interaction target).

CLS uses session-window accumulation per the spec; layout shifts within 500 ms of user input (hadRecentInput: true) are excluded. INP uses the longest interaction (interactionId) across the page.

js_error

Fired on window.onerror and on unhandledrejection. Properties:

Property Source
message First 1 KB of error message.
source Script URL where the error originated.
lineno Line number.
colno Column number.
stack First 4 KB of stack trace.
error_kind 'error' | 'unhandledrejection'.

A per-page dedupe limit (DEDUPE_LIMIT = 10) caps the number of distinct errors reported per page-view to avoid drowning the collector when a third-party script is in a loop.

history_change

Fired on every pushState / replaceState / popstate. The tracker also fires a fresh page_view immediately after — this event exists so SPA-routing-only analyses (e.g. "how often does the route change without a page_view succeeding?") are possible.

The karma() JavaScript API

The tracker exposes one global function: karma(). It accepts a command name as the first argument:

karma('event', name, properties, pageOverrides)

Emit a custom event. The name is required and goes through the same 80-char / non-empty validation as auto events. properties is an arbitrary JSON object capped at 16 KB.

karma('event', 'signup', {
  plan: 'pro',
  source: 'pricing_page',
  amount_usd: 49,
});

pageOverrides is rarely needed — use it to override page_url / page_path / page_title / referrer when the current location values are wrong (e.g. you're firing a delayed conversion after a navigation already happened).

karma('page', properties, pageOverrides)

Emit a page_view manually. Useful when you want to attribute an SPA route change to a custom name — pageOverrides can rewrite the page fields:

karma('page', { variant: 'experiment-A' }, { page_path: '/checkout/step-2' });

karma('identify', userId, traits)

Bind the current visitor_id to a known user. After this call, every subsequent event includes user_id, and an identify event is emitted so the identity-stitching job can backfill historical visitor activity to the same user.

karma('identify', 'user_12345', { email: 'jane@example.com', plan: 'pro' });

Calling identify with a different userId later in the same session re-binds without losing history; the identity_map BQ table records the timeline.

karma('consent', state)

Update the consent state. The tracker tracks analytics and marketing flags; until set, both are null (which the server interprets as "consent not given"). DNT and GPC are read live from the browser, not from this state.

// Typical cookie-banner flow:
karma('consent', { analytics: true, marketing: false });

The consent block is included in every subsequent event so server-side filters can drop or keep events per-event, not just per-session.

karma('flush', reason?)

Force-flush the current queue. The tracker auto-flushes every 2 s, on every 20-event batch fill, and on pagehide / visibilitychange: hidden. Use karma('flush') only when you're about to navigate via something the browser doesn't fire pagehide for (extremely rare).

How Batches Are Sent

What's Not Auto-Collected (and Why)