p1-chaos-tracker
v0.9.0
Published
Intent-aware analytics tracker — behavioral signals, funnel classification, component visibility tracking, AI crawler tracking
Maintainers
Readme
p1-chaos-tracker
Intent-aware analytics tracker that classifies visitor behavior into funnel stages — Awareness, Interest, Consideration, Decision, Evaluation — with FAISS-ready signal vectors.
Install
npm install p1-chaos-trackerUsage
Script tag (CDN)
<script defer data-site-id="YOUR_SITE_ID" src="https://unpkg.com/p1-chaos-tracker/dist/chaos-tracker.iife.js"></script>The tracker auto-initializes when data-site-id is present. Access the API via window.ChaosTracker.
ES Module
import { initChaosTracker } from 'p1-chaos-tracker';
const tracker = initChaosTracker({ siteId: 'YOUR_SITE_ID' });CommonJS
const { initChaosTracker } = require('p1-chaos-tracker');
const tracker = initChaosTracker({ siteId: 'YOUR_SITE_ID' });Next.js / React
'use client';
import { useEffect, useRef } from 'react';
import { initChaosTracker } from 'p1-chaos-tracker';
export function ChaosProvider({ siteId }) {
const tracker = useRef(null);
useEffect(() => {
tracker.current = initChaosTracker({ siteId, allowLocalhost: true });
return () => tracker.current?.destroy();
}, [siteId]);
return null;
}Config
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| siteId | string | null | Your site identifier |
| apiBase | string | "/api/v1/intent" | API base URL for event ingestion |
| allowLocalhost | boolean | false | Enable tracking on localhost |
| hasConsent | (signals) => boolean | null | Consent callback; receives { gpc, dnt, optedOut }. If omitted, consent falls back to GPC/DNT + the opt-out flag |
| overrideBrowserSignals | boolean | false | Allow a true consent result to override an active GPC/DNT signal (e.g. Brave). Requires a lawful basis; optOut() still wins |
| hashIdentifiers | boolean | false | SHA-256 hash hashFields traits client-side before sending. Fails closed (drops the field) if Web Crypto is unavailable |
| hashFields | string[] | ["email","name","phone"] | Trait keys treated as PII for hashing |
| captureUrlParams | boolean \| string[] | false | Query-string capture on url/referrer. false strips all; true keeps all; array = allowlist. Hash fragment always stripped |
| samplingRate | number | 1 | Per-visitor fraction (0–1). A visitor is always or never tracked, so sessions stay complete. Sampled-out → init returns null |
| localIntentOnly | boolean | false | Privacy mode: resolve intent on-device and send only compact intent_snapshot events — no raw click/scroll/URL stream. See INTENT_MODEL.md. Suppresses conversion events — conversions require full mode |
| detectPaymentParams | boolean | true | Auto-detect payment success-page return params on init + SPA nav and emit a conversion once per id: Stripe session_id (cs_…), Polar checkout_id, LemonSqueezy order_id. Only the provider + opaque id are sent — never PII |
Bot assessment (report-only)
The tracker classifies likely bots on the client and emits the result on every
session_start — it does not block init or drop events. Crawlers,
headless browsers, and automation tools are tracked like any other visitor so
the backend can segment them in reports.
import { isBotUA, assessBotSignals } from 'p1-chaos-tracker';
assessBotSignals({
user_agent: navigator.userAgent,
webdriver: navigator.webdriver === true,
});
// → { flags, score, is_likely_bot, suppress_init }session_start.data.device includes:
| Field | Description |
|-------|-------------|
| user_agent | Raw UA string |
| bot_assessment.flags | Active signals, e.g. bot_ua, webdriver, no_plugins |
| bot_assessment.score | 0–100 likelihood score |
| bot_assessment.is_likely_bot | true when score ≥ 40 or a hard automation flag fired |
The Go ingest service (sublime-go) persists these fields on chaos.visitor_states
and surfaces likely_bot_visitors in daily rollups / dashboard timeseries.
See BACKEND_EVENT_SCHEMA.md.
AI / search crawler tracking (server-side)
Named AI agents (GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, …) usually
fetch raw HTML and skip browser JavaScript, so they never hit the web tracker.
Use the server package to report those requests separately from human intent.
npm install p1-chaos-tracker// middleware.ts (Next.js) — do NOT await when you pass event
import { trackAICrawlerRequest } from "p1-chaos-tracker/server/ai-crawlers";
export function middleware(request, event) {
trackAICrawlerRequest(request, event, {
siteId: process.env.CHAOS_SITE_ID!,
apiBase: process.env.CHAOS_API_BASE || "https://ap1.mathematica.ai/api/v1/intent",
apiKey: process.env.CHAOS_API_KEY, // optional Bearer
});
return NextResponse.next();
}// Express (Node 18+) — non-blocking via res.finish
import { createExpressAICrawlerMiddleware } from "p1-chaos-tracker/server/ai-crawlers";
app.set("trust proxy", 1);
app.use(
createExpressAICrawlerMiddleware({
siteId: process.env.CHAOS_SITE_ID!,
apiBase: process.env.CHAOS_API_BASE!,
publicOrigin: process.env.PUBLIC_ORIGIN, // Docker / Cloud Run
})
);| Concern | Browser bot_assessment | server/ai-crawlers |
|---------|--------------------------|----------------------|
| Runtime | JS sessions | Edge / Node middleware |
| Taxonomy | Likelihood score | provider × agent × category |
| Storage | visitor_states | POST …/ai-crawls (no FAISS) |
| Discovery files | — | robots.txt, llms.txt, sitemaps |
Categories (client hints): answer_fetch | search_index | training | other.
Backend reclassifies from raw UA + IP.
Docs: AI_CRAWLER_INTEGRATION.md (setup) · docs/AI_CRAWLER_TRACKING_DESIGN.md (architecture)
Intent-only import is unchanged: import { createIntentClient } from "p1-chaos-tracker/server".
API
const tracker = initChaosTracker({ siteId: 'xxx' });
// Custom event
tracker.track('plan_toggle', { plan: 'pro' });
// Record a conversion (the outcome label the intent model learns from).
// value is coerced to a number; currency/order_id/provider are trimmed;
// any extra fields are preserved. Never pass raw PII (e.g. an email).
tracker.convert({ value: 49, currency: 'USD', order_id: 'ord_123', provider: 'stripe' });
// Stripe / Polar / LemonSqueezy success pages are also auto-detected from the
// return URL (see `detectPaymentParams`), so no explicit call is needed there.
// Note: conversions are suppressed in `localIntentOnly` mode.
// Identify user after login
tracker.identify('user_123', { email: '[email protected]', plan: 'pro' });
// Get current intent (for UI morphing or Stripe linkage)
const intent = tracker.getIntent();
// → {
// stage: 'consideration', score: 3, confidence: 0.78,
// distribution: { awareness: 0.05, interest: 0.12, consideration: 0.78, decision: 0.04, evaluation: 0.01 },
// frustrated: false,
// stage_history: [{ stage: 'awareness', at }, { stage: 'consideration', at }],
// time_to_current_stage_ms: 42000,
// signal_sequence: ['first_visit', 'pages_gte_2', 'pricing_over_30s'],
// signals: [...], visitor_id: 'ct_...', session_duration, pages_viewed, scroll_depth
// }
// See INTENT_MODEL.md for how the score, decay, and distribution work.
// Fire a signal manually
tracker.signal('roi_calc_use');
// Cart tracking
tracker.cartAdd({ product: 'Pro Plan', price: 49 });
tracker.cartComplete();
// Search tracking
tracker.searchQuery('enterprise pricing');
// Flush events immediately
tracker.flush();
// Cleanup (removes all listeners, restores history, flushes)
tracker.destroy();Data Attributes
Declarative tracking without code:
<!-- Track clicks by intent signal -->
<button data-chaos-track="demo">Book a Demo</button>
<button data-chaos-track="trial">Start Free Trial</button>
<button data-chaos-track="checkout">Proceed to Checkout</button>
<button data-chaos-track="comparison">Compare Plans</button>
<button data-chaos-track="roi_calc">Calculate ROI</button>
<button data-chaos-track="download">Download Guide</button>
<!-- Track element visibility on scroll -->
<section data-chaos-scroll="testimonials">...</section>
<section data-chaos-scroll="pricing_table">...</section>
<!-- Rich component tracking with metadata -->
<div data-chaos-component="hero_cta" data-chaos-meta='{"variant":"A"}'>
...
</div>
<!-- Override page type detection -->
<body data-chaos-page-type="pricing">...</body>
<!-- Classify form type -->
<form data-chaos-form="demo">...</form>
<!-- Track media engagement (fires media_engaged @50%, media_completed @95%/end) -->
<video data-chaos-media="demo_video" src="..."></video>Components with data-chaos-component or data-chaos-scroll added after page load are automatically observed via MutationObserver — no manual registerComponent() call needed.
Rich Metadata
Any element with data-chaos-meta will have its JSON parsed and included in all events for that element (visibility milestones, hover, click):
<section
data-chaos-component="pricing_table"
data-chaos-meta='{"productId":"pro","variant":"annual"}'
>
...
</section>Attribution & UTM Tracking
UTM parameters (utm_source, utm_medium, utm_campaign, utm_term, utm_content) and ad click IDs (gclid, fbclid, msclkid, etc.) are captured on first page load of a session and persisted in sessionStorage.
They are included in every pageview and signal event, so the backend always has attribution context even if the initial beacon failed.
Session Persistence
Session ID, start time, and page count are persisted in sessionStorage. This means:
getSessionDuration()returns time since the session started, not just the current pagepages_viewedaccumulates across full page loads (not just SPA navigations)- Bounce rate can be accurately computed (single-pageview sessions)
Sessions expire after 30 minutes of inactivity (standard analytics definition).
Privacy
Consent is evaluated in this priority order:
tracker.optOut()— an absolute kill switch. Sets thechaos_optoutflag, and also stops the live instance immediately (discarding queued and buffered events). Nothing — not even ahasConsentcallback — overrides it.tracker.optIn()clears the flag.- GPC / DNT —
navigator.globalPrivacyControl === trueornavigator.doNotTrack === "1"veto tracking by default, even when ahasConsentcallback returnstrue. GPC is a legally recognized opt-out under CCPA/CPRA. To make a consent banner authoritative over a browser signal (e.g. Brave defaults GPC on), setoverrideBrowserSignals: trueand ensure you have a lawful basis — it is never the default, and logs a warning. hasConsentcallback — receives{ gpc, dnt, optedOut }so it can make an informed decision. Must returntruefor tracking to start.
EU / GDPR: with no
hasConsentcallback the tracker uses an opt-out model (it runs unless GPC/DNT or the opt-out flag is set). GDPR/ePrivacy require opt-in consent for analytics, so EU integrators must supply ahasConsentcallback wired to their consent management platform.
Other protections:
- PII hashing —
hashIdentifiers: trueSHA-256 hashes thehashFieldstraits before sending. It fails closed: if Web Crypto is unavailable (e.g. a non-HTTPS origin) the fields are dropped, never sent in plaintext. Note this is pseudonymization, not anonymization — a hashed email is still personal data under GDPR. - URL redaction — query strings and hash fragments are stripped from the
url/referrerfields by default (they routinely carry tokens and PII). Opt specific params back in withcaptureUrlParams. UTM / ad-click params are captured into attribution separately. - Durable visitor cookie — the tracker prefers a first-party
chaos_vidcookie for identity (it surviveslocalStorageeviction by Safari ITP / Brave / incognito, which otherwise fragments sessions). When onlylocalStorageholds the id, the client writes it to achaos_vidcookie (Path=/,SameSite=Lax, ~730-dayMax-Age,Secureonly over HTTPS) so it survives and is readable next load. The cookie is only written after the consent gate passes — an opted-out visitor or an active GPC/DNT signal means no cookie is set (and no events sent). See INTENT_MODEL.md.
Reliability
- Retry buffer: failed sends are stored in
localStorage(up to 100 events, FIFO eviction) and retried on next flush - Batch cap: events are sent in batches of 50 to avoid payload size limits
- Event IDs: every event carries a unique
event_idfor server-side deduplication - Heartbeat pauses while the tab is hidden to save battery and network
Stripe Revenue Linkage
Pass the visitor_id to Stripe Checkout for direct revenue attribution:
const intent = tracker.getIntent();
// Server-side: create checkout session
const session = await stripe.checkout.sessions.create({
client_reference_id: intent.visitor_id,
// ...
});Server-Side Intent (SSR / personalization)
Read a visitor's resolved intent on the server (Next.js, Express, edge) to morph UI, gate pricing, or branch email flows. Backed by a short-TTL cache, a hard timeout, and per-visitor single-flight — and it never throws, so it's safe in a render path.
import { createIntentClient } from 'p1-chaos-tracker/server';
const intent = createIntentClient({
apiBase: 'https://ap1.example.com/api/v1/intent',
siteId: 'YOUR_SITE_ID',
apiKey: process.env.CHAOS_API_KEY, // optional Bearer token
});
// In a request handler / server component:
const snap = await intent.get(visitorId); // visitor_id cookie from the client
if (snap?.stage === 'decision') {
// show the high-intent CTA
}
// Many at once (fans out cached, single-flighted /resolve calls):
const map = await intent.getBatch([idA, idB, idC]);Reads GET /api/v1/intent/resolve (see BACKEND_ENDPOINTS.md §2). Returns
null for unknown visitors, timeouts, or errors.
Intent Stages
| Stage | Weight | Example Signals | |-------|--------|----------------| | Awareness | 1 | first_visit, homepage_under_20s | | Interest | 2 | pages_gte_2, repeat_within_day | | Consideration | 3 | pricing_over_30s, demo_form_start, form_abandon | | Decision | 4 | checkout_entered, trial_signup_attempt | | Evaluation | 5 | case_study_read, feature_deepdive_over_1min |
Intent Model
Stages are scored with per-signal weights and recency decay, surfaced as
a probability distribution + confidence (not just a single stage), with
velocity (stage_history, time_to_current_stage_ms) and a frustrated
flag from friction signals. Intent also persists across sessions with a
decaying memory. High-intent behavioral signals captured automatically include
hesitation (hovering a CTA without clicking), copy of a price/contact,
reaching a payment field, media engagement, and rage/dead clicks.
See INTENT_MODEL.md for the full scoring model, the signal
catalog, the privacy-preserving localIntentOnly mode, durable cookie identity,
and the roadmap to learn weights from conversion outcomes.
Events Emitted
| Event | When | Key Data Fields |
|-------|------|-----------------|
| session_start | Tracker init | visit_count, device (incl. user_agent, bot_assessment), attribution, client_hints |
| session_end | Previous session TTL expired | reason |
| session_bounce | Unload with 1 page & <10s | time_on_page, scroll_depth |
| pageview | Every page load + SPA nav | page_type, pages_viewed, attribution |
| entry_page | First pageview of session | pathname, hostname, referrer, attribution |
| exit_page | SPA nav away + unload | pathname, time_on_page, scroll_depth |
| page_exit | Unload | full signal summary, components |
| signal | Intent signal fired | signal name, intent_stage, attribution |
| click | [data-chaos-track] click | track attr, text, meta |
| outbound_click | External link click | url, hostname, pathname, page_type |
| form_focus | Form field focus | form_type, field |
| form_submit | Form submission | form_type |
| form_abandon | Unload with active form | form_type, field, time_spent_ms |
| component_visible | Scroll visibility milestone | component, visibility %, meta |
| component_view_end | Component leaves viewport | view_duration_ms, max_visibility |
| heartbeat | Every 10s (not when hidden) | intent, duration, scroll, components |
| exit_intent | Mouse leaves viewport top | page_type, time_on_page |
| identify | tracker.identify() call | user_id, traits (optionally hashed) |
| custom | tracker.track() call | name, meta |
| conversion | tracker.convert() call or auto-detected payment success page | value, currency, order_id, provider (+ auto: true when auto-detected) |
Development
yarn install
yarn test # vitest
yarn build # rollup → dist/License
MIT
