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.
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 }
}
event_kind is one of auto | custom | tag | s2s. Only custom and tag events should appear in funnels / event definitions; auto events are the raw stream.visitor_id is a UUID stored in localStorage per origin (lasts 2 years).session_id is a 30-minute idle-timeout UUID stored in localStorage.page_viewFired 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.
clickFired 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_depthFired 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_submitFired 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_vitalsThe 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_errorFired 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_changeFired 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.
karma() JavaScript APIThe 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).
navigator.sendBeacon first (text/plain blob to dodge CORS preflight), fetch with keepalive: true as fallback, GET pixel beacon (/collect/:siteKey/p?e=<b64>) as last resort.202 with { success, accepted, filtered }. filtered > 0 usually means events were dropped server-side due to DNT/GPC.visitor_id is per-origin in localStorage. There is no third-party cookie.page_url to 2 KB but doesn't redact specific params. If a query parameter contains PII, redact it in your own URL before navigating.