Files
agency/.pnpm-store/v3/files/bb/5d535e23595e56aa383dc386271cd0196f4632214bca32ec93e24c67a50cbbfca746c57d4f5da522e5bbd6a97dd3ae892bf0c40ddda8a7eb905fba1fe9cd74
hanzo-agent 5af6960b87 run project
Run: sess_f37640604ae254cb4d11dc6217f7b721
2026-09-30 00:04:56 +00:00

746 lines
34 KiB
Plaintext

// The framework-agnostic event client. ONE API surface over TWO orthogonal
// planes, sharing one session and one identity:
//
// 1. EVENT STREAM — buffered pageview/event/identify/group, flushed as ONE
// batch to the Hanzo Cloud entry point:
// POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
// Cloud resolves the tenant server-side (validated session, or the signed
// publishable key) and stamps it; the client NEVER sends the org.
//
// 2. ERROR PLANE — every captured exception is ALSO framed as a real Sentry
// envelope and POSTed to the error host named by the DSN:
// POST {dsn.base}/envelope/?sentry_key=… ({base} = /v1/event/{projectId})
// This is what reaches sentry.hanzo.ai (issues, grouping, stack frames).
//
// These are NOT the same pipe and one does NOT feed the other. The event stream
// stores a `type:'error'` row in the cloud event warehouse (readable via
// GET /v1/errors) — that is product signal, not error tracking. There is no
// server-side fan-out from /v1/event into Sentry; without the envelope below,
// nothing ever reaches sentry.hanzo.ai. An earlier revision of this file claimed
// the one entry point was "lensed server-side into … error tracking (sentry)". It was
// wrong, and it silently cost the fleet all of its error telemetry.
//
// The error plane is inert (fail-safe) when no DSN is configured: nothing is
// sent, nothing throws, and the event stream is unaffected.
//
// Auth is orthogonal — the SAME body to the SAME endpoint, differing only in how the
// caller proves its tenant:
//
// • cookie/session app (host:'') — same-origin credentials ride the request.
// • bearer app (getToken) — Authorization: Bearer <jwt>.
// • publishable-key app (ingestKey: 'pk-…') — ?ingest_key=pk-… on the query, on
// the fetch and the beacon alike, with a text/plain body. Write-only and safe
// to ship in a bundle; the endpoint resolves it to an org server-side. The query
// is the carrier neither send needs a header for, which is what keeps both
// CORS-simple and therefore sendable from a customer's own origin.
//
// The wire is the canonical `Event` (== the cloud CaptureEvent): its `type` field
// is what Cloud folds to event_type='error', which is how the event WAREHOUSE
// classifies the row (GET /v1/errors). That is the extent of it — the fold does
// not forward anything to Sentry. The error dashboard is fed only by the envelope
// in plane 2 above, and only when a DSN is set.
import {
parseAttribution,
hasAttribution,
deriveChannel,
} from './attribution'
import { dsnForProduct } from './dsn'
import { keyForPage } from './org'
import { EXCEPTION, PAGEVIEW } from './events'
import { exceptionProperties } from './exception'
import { scrubText } from './scrub'
import {
buildEnvelope,
buildSentryEvent,
normalizeError as normalizeThrowable,
parseDsn,
type ErrorIdentity,
} from './sentry'
import {
anonId,
sessionId,
getFirstTouch,
setFirstTouchOnce,
getCohort,
mergeCohort,
} from './storage'
import { uuidv7 } from './uid'
import type {
AnalyticsConfig,
Attribution,
CaptureErrorOptions,
Cohort,
Dsn,
EventKind,
Exception,
Transport,
WireEvent,
} from './types'
import { VERSION } from './version'
export { VERSION }
const EVENT_PATH = '/v1/event' // the ONE canonical ingestion entry point
const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
// The beacon body's type. text/plain is CORS-SAFELISTED, which is the whole
// property: a safelisted type makes the POST a SIMPLE request, and a simple
// request needs no preflight. An unloading document does not get a second round
// trip, so cross-origin a preflighted beacon is never sent at all. The endpoint reads
// the raw body and dispatches on its first non-space byte, so the type names the
// CORS class and nothing else.
const BEACON_CONTENT_TYPE = 'text/plain'
/** readEnvDsn resolves a DSN from the public env when config omits one, so an app
* gets the error plane by setting ONE build-time variable and nothing else.
* Next/Vite inline these at build; the access is guarded so it is safe in a bare
* browser and during SSR/prerender where `process` may not exist. */
function readEnvDsn(): string | undefined {
try {
if (typeof process !== 'undefined' && process.env) {
return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || undefined
}
} catch {
/* no process — browser without inlined env */
}
return undefined
}
/** readEnv reads an inlined build-time variable, guarded like readEnvDsn. */
function readEnv(name: string): string | undefined {
try {
if (typeof process !== 'undefined' && process.env) return process.env[name] || undefined
} catch {
/* no process */
}
return undefined
}
/** appendQuery adds a single query param to a URL string — used to carry a
* publishable key on either send (?ingest_key=…). */
function appendQuery(url: string, key: string, value: string): string {
return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)
}
/** Normalize anything thrown (Error | string | unknown) into an Exception. */
/** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
* the event stream's Exception shape. ONE normalizer serves both planes: a thrown
* object may define `name`/`message`/`stack` as throwing getters, and when each
* plane rolled its own reader the stream still lost the report that the error
* plane had already survived. */
function normalizeError(err: unknown): Exception {
const n = normalizeThrowable(err)
return { type: n.name, message: n.message, stack: n.stack }
}
// React Native defines a `window` but no `document` (and no `window.location`),
// so `typeof window !== 'undefined'` alone took the browser path there and
// `init()` threw on `window.location.search`. Requiring `document` makes RN read
// as non-browser: `init()` returns early (skips attribution + the visibility/
// unload listeners RN doesn't have), while `capture()`/`flush()` — which are NOT
// gated on this and post through plain `fetch` — still emit. So the client is
// inert-safe under SSR/Node and live on RN, without a second transport.
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined'
/** serializeBatch stringifies a batch, salvaging what it can. `properties` is
* arbitrary caller data — a DOM node, a React synthetic event, an axios error are
* all circular, and a getter on one can throw — so a whole batch must never be
* lost to a single poisoned event. Falls back to per-event serialization, then to
* keeping the event and dropping its properties. Returns null only when nothing
* at all survives. */
function serializeBatch(batch: WireEvent[]): string | null {
try {
return JSON.stringify({ batch })
} catch {
/* one bad event — salvage the rest below */
}
const parts: string[] = []
for (const e of batch) {
try {
parts.push(JSON.stringify(e))
} catch {
try {
// Keep the event (identity, session, type all matter); drop the payload
// that could not be serialized, and say so rather than lying by omission.
parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }))
} catch {
/* unsalvageable — drop this ONE event, never the batch */
}
}
}
return parts.length > 0 ? '{"batch":[' + parts.join(',') + ']}' : null
}
/** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
* navigator.sendBeacon for page-unload beacons.
*
* A publishable key rides the ?ingest_key query on BOTH, which is what keeps
* both CORS-simple: the query is the one carrier neither send needs a header
* for, and a simple request leaves the browser from any origin — permission is
* asked to READ a cross-origin response, never to send one. That is what lets a
* bundled client run on a customer's own page. A JWT is first-party by
* construction, so it keeps Authorization and the session it travels with.
*
* `contentType` names the FETCH request's Content-Type (the error plane sets the
* envelope type there, and keeps the header carrier with it). The beacon body is
* always BEACON_CONTENT_TYPE: its type is a CORS class, not a payload
* description, and a beacon that is not a simple request is never sent. */
class DefaultTransport implements Transport {
send(
url: string,
body: string,
opts: {
beacon: boolean
token?: string
ingestKey?: string
contentType?: string
debug?: boolean
},
): void {
if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {
const beaconUrl = opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url
try {
// The answer is whether the agent QUEUED the batch — false for a body past
// the beacon size limit, or a full queue. Only a queued batch ends the
// send; a refused one falls through to the keepalive fetch below.
const queued = navigator.sendBeacon(
beaconUrl,
new Blob([body], { type: BEACON_CONTENT_TYPE }),
)
if (queued) return
if (opts.debug) console.warn('[event] beacon refused, falling back to fetch')
} catch {
/* fall through to fetch */
}
}
if (typeof fetch !== 'function') return
// A publishable key makes the send a CORS SIMPLE request: the key rides the
// query, the body is text/plain, and nothing asks for credentials. That is what
// lets a bundled client run on a customer's own page — every send from there is
// cross-origin, and an Authorization header or a JSON type preflights the POST
// for an origin that cannot pass one. A JWT is first-party by construction, so
// it keeps the header and the session it travels with, and so does the error
// plane, which names its own envelope type.
const simple = opts.ingestKey !== undefined && opts.contentType === undefined
const headers: Record<string, string> = {
'Content-Type': simple ? BEACON_CONTENT_TYPE : (opts.contentType ?? 'application/json'),
}
const bearer = simple ? undefined : (opts.ingestKey ?? opts.token)
if (bearer) headers.Authorization = `Bearer ${bearer}`
void fetch(simple ? appendQuery(url, 'ingest_key', opts.ingestKey!) : url, {
method: 'POST',
headers,
body,
keepalive: true,
credentials: simple ? 'omit' : 'include',
})
.then((res) => {
// Telemetry loss never throws into the app — but silence is how this
// client lost the fleet's errors in the first place. Under `debug`, say
// so. A rejected ingest (the CORS allowlist 403s non-production origins,
// so local dev NEVER reports) is otherwise indistinguishable from success.
if (!res.ok && opts.debug) {
console.warn('[event] ingest rejected', res.status, url.split('?')[0])
}
})
.catch((e: unknown) => {
if (opts.debug) console.warn('[event] ingest failed', url.split('?')[0], e)
})
}
}
export class Analytics {
private cfg: Required<
Pick<AnalyticsConfig, 'product' | 'batchSize' | 'flushIntervalMs' | 'enabled' | 'captureErrors'>
> &
AnalyticsConfig
private transport: Transport
private queue: WireEvent[] = []
private timer: ReturnType<typeof setTimeout> | null = null
private personId?: string
private attribution: Attribution = { utm: {} }
private cohort: Cohort = {}
private started = false
/** The view pageview() last counted — path + location. */
private counted?: string
/** Parsed error-plane DSN, or null when the plane is inert. */
private dsn: Dsn | null
/** Guards against an error thrown *inside* the error path re-entering it. */
private reentrant = false
constructor(config: AnalyticsConfig) {
this.cfg = {
host: DEFAULT_HOST,
batchSize: 20,
flushIntervalMs: 5000,
enabled: true,
captureErrors: true,
...config,
// The publishable key resolves from ONE live source, never a literal baked
// beside the code. Most specific first:
// 1. an explicit `ingestKey` in config;
// 2. NEXT_PUBLIC_PUBLISHABLE_KEY, the inlined build-time env the fleet
// carries end to end — KMS `deploy/PUBLISHABLE_KEY` -> the PUBLISHABLE_KEY
// build-arg -> the NEXT_PUBLIC_ prefix Next inlines.
//
// 3. the org that owns the HOST this page is served from (`org.ts`).
//
// Step 3 fires only when a surface configured NOTHING, so it never overrides
// the KMS chain above — it replaces going dark with reporting to the right
// brand. That is what a static export needs: `output: export` inlines no env,
// so before this the only way to report was to commit a key literal, and the
// fleet grew one copy per site of a value with a single source.
//
// A surface no brand claims still has no key, and the edge REFUSES an
// unattributed event (401 `ingest_key_required`) — the reserved `$public`
// tenant that once caught keyless beacons is retired, and anonymous ingest is
// refused at every edge on every brand host. So there is no quiet fallback to
// rely on: an event lands in the org a credential names, or it does not land.
ingestKey:
config.ingestKey ?? readEnv('NEXT_PUBLIC_PUBLISHABLE_KEY') ?? keyForPage(),
}
this.transport = config.transport ?? new DefaultTransport()
// Error plane, most specific source first: an explicit DSN wins, then the
// inlined build-time env (a per-deploy override), then the product registry —
// whose DSN carries the SAME resolved key (not a baked one), so declaring
// `product` + providing the key is enough to report errors. Malformed or
// absent => null => inert, never throwing into the host app.
this.dsn = parseDsn(
config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey)
)
}
/** adopt gives this client a credential it does not have. The key belongs to
* the stream, not to whichever caller happened to ask for the handle first,
* so a later caller carrying one hands it over. The error plane derives from
* the key, so it comes up here too when it was inert for want of one.
* Present fields are never overwritten: the first caller's key stays. */
adopt(config: AnalyticsConfig): void {
if (config.ingestKey && !this.cfg.ingestKey) this.cfg.ingestKey = config.ingestKey
if (config.getToken && !this.cfg.getToken) this.cfg.getToken = config.getToken
if (!this.dsn) {
this.dsn = parseDsn(
config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey),
)
}
}
/** errorPlaneEnabled reports whether captured exceptions can actually reach the
* error host. False means a DSN was never configured — the documented
* fail-safe. Exposed so an app (or a test) can assert its wiring instead of
* discovering months later that nothing was ever reported. */
get errorPlaneEnabled(): boolean {
return this.dsn !== null
}
/** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
* plane is inert. Diagnostics only. */
get errorIngestUrl(): string | undefined {
return this.dsn?.ingestUrl
}
/** init is idempotent and browser-only for its side effects: capture first-touch
* attribution, hydrate cohort, register the unload flush, and (unless opted out)
* auto-capture unhandled errors. Safe to call from a React effect on every
* render. */
init(): void {
if (this.started || !this.cfg.enabled) return
this.started = true
if (!isBrowser()) return
const parsed = parseAttribution(window.location.search, document.referrer)
this.attribution = hasAttribution(parsed)
? setFirstTouchOnce(parsed)
: getFirstTouch() ?? parsed
this.cohort = mergeCohort({
channel: this.attribution.channel ?? deriveChannel(this.attribution),
refCode: this.attribution.refCode,
})
const flushHidden = () => {
if (document.visibilityState === 'hidden') this.flush(true)
}
window.addEventListener('visibilitychange', flushHidden)
window.addEventListener('pagehide', () => this.flush(true))
// Auto error capture — the drop-in @sentry replacement. Unhandled errors and
// rejected promises are reported on BOTH planes: a Sentry envelope to the DSN
// host (what reaches the error dashboard — requires a DSN) and a type:'error'
// event on the stream (product signal in the warehouse).
if (this.cfg.captureErrors) {
window.addEventListener('error', (e: ErrorEvent) => {
this.captureError(e.error ?? e.message, { handled: false })
})
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
this.captureError(e.reason, { handled: false })
})
}
}
/** identify binds the current visitor to a stable person id (post-login). */
identify(personId: string, traits?: Record<string, unknown>): void {
this.personId = personId
this.enqueue('identify', undefined, { properties: traits })
}
/** group associates the visitor with an org/team (analytics grouping, not the
* server tenant — the server still derives tenant from the session). */
group(groupId: string, traits?: Record<string, unknown>): void {
this.enqueue('group', undefined, { groupId, properties: traits })
}
/** pageview records a $pageview for the current (or given) location, once per
* view. A view is the path plus the full location, so a query or hash change
* is a new one and a repeat call for the same place is not. */
pageview(path?: string, properties?: Record<string, unknown>): void {
// No `url` here: build() reads window.location.href for every event, in the
// same tick, so this recomputed it to the identical value. Only `path` is
// passed, because a route change fires before window.location has caught up
// and the caller's value has to win.
const p = path ?? (isBrowser() ? window.location.pathname : undefined)
// Counting a page once is the client's rule, not each caller's. A page holds
// several emitters that each correctly believe they count the first view —
// the provider's autoPageview, a router hook, a nav autocapture — and per
// emitter state cannot see the others. On the stream they are one page.
const view = (p ?? '') + '\0' + (isBrowser() ? window.location.href : '')
if (view === this.counted) return
this.counted = view
this.enqueue('pageview', PAGEVIEW, { path: p, properties })
}
/** capture records a named product event with optional properties. Commerce
* fields (productId/quantity/revenue/currency) may be passed for order events. */
capture(
event: string,
properties?: Record<string, unknown>,
commerce?: Pick<WireEvent, 'productId' | 'quantity' | 'revenue' | 'currency'>,
): void {
this.enqueue('event', event, { properties, ...commerce })
}
/** captureError reports a caught error, an unhandled rejection, a React render
* error, or a manual report to BOTH planes, from one call:
*
* - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
* that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
* Inert when no DSN is configured.
* - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
* an error stays correlated with the session's pageviews for product
* analysis (readable via GET /v1/errors).
*
* Both carry the SAME session and subject id, so an error and the pageview
* before it join up. Never throws back into the app; errors are higher-signal
* than pageviews, so both planes flush promptly (a crash may unload the page
* moments later). */
captureError(err: unknown, context?: CaptureErrorOptions): void {
// A failure inside the error path must not recurse through the global handlers.
if (this.reentrant) return
this.reentrant = true
try {
// ERROR PLANE FIRST, in its own try. The planes are independent, so neither
// may be able to starve the other: `properties` is arbitrary caller data
// (a DOM node, a React synthetic event, an axios error — all circular and
// all common), and serializing it on the event stream can throw. When the
// stream ran first, that throw escaped to the outer catch and the crash
// report was never sent — silently losing exactly the signal this client
// exists to deliver. Order and isolation are the fix.
try {
this.sendError(err, context)
} catch {
/* the error plane must never take the event stream down with it */
}
try {
const handled = context?.handled ?? true
const ex = normalizeError(err)
ex.handled = handled
// NAME: the reserved '$exception', never the message. The message was the
// name until 0.3.20, which put every distinct error string — one per failed
// chunk id, per ResizeObserver notification — permanently into the event
// taxonomy, and left Error Tracking (which reads this exact name) at zero.
//
// TYPE 'event', not 'error'. `type` alone picks the storage plane: 'error'
// routes to the error plane, which the product-event projection does not
// read, so an exception filed there is invisible to Error Tracking however
// well-formed it is. The full error record still reaches the error plane as
// a Sentry envelope above — this row is the product-analytics breadcrumb,
// which is what keeps a crash correlated with the session's pageviews.
//
// `error` is still carried: the server folds it into properties.$exception
// (scrubbing message and stack on the way), which is the shape existing
// readers bind to.
this.enqueue('event', EXCEPTION, {
error: ex,
properties: {
...context?.properties,
...exceptionProperties(err, {
handled,
id: uuidv7(),
level: context?.level,
}),
},
})
this.flush()
} catch {
/* nor the reverse */
}
} finally {
this.reentrant = false
}
}
/** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
* every subsequent event. */
setCohort(patch: Cohort): void {
this.cohort = mergeCohort(patch)
}
/** flush drains the buffer to the server as ONE batch through the ONE ingest
* endpoint POST /v1/event, body { batch: [Event…] }. beacon=true selects the
* unload-safe transport. Auth is orthogonal to the wire:
*
* • publishable key set → rides ?ingest_key=pk-… on both sends, keeping each
* a CORS-simple request that any origin may send.
* • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
* carry a header, so token apps fall back to keepalive fetch on unload).
* • else a cookie app rides same-origin credentials (beacon carries the
* cookie fine).
*/
flush(beacon = false): void {
if (!this.cfg.enabled || this.queue.length === 0) return
const batch = this.queue
this.queue = []
this.clearTimer()
// A publishable key and a bearer JWT are mutually exclusive credentials, and the
// BEARER WINS. It names a real principal and resolves to THAT person's org;
// a pk- names one org for everybody holding it. So the key is what attributes
// a visitor nobody has vouched for, and it must never displace someone who
// has been.
//
// The precedence used to run the other way, which was survivable only while
// the key had to be passed in code. Once it also resolves from the build env,
// key-wins means setting one variable silently blanks every signed-in user's
// token and re-files their events under whichever org minted the key — on a
// console served to several brands from one bundle, that is a cross-tenant
// leak introduced by an env var.
const token = this.cfg.getToken?.() ?? undefined
const key = token ? undefined : this.cfg.ingestKey?.trim() || undefined
// Only a bearer JWT blocks the beacon: sendBeacon cannot set an Authorization
// header. A pk- rides ?ingest_key; a cookie rides credentials.
const useBeacon = beacon && !token
const body = serializeBatch(batch)
if (body === null) {
if (this.cfg.debug) console.debug('[event] flush → dropped, batch unserializable')
return
}
if (this.cfg.debug) console.debug('[event] flush →', EVENT_PATH, batch.length)
this.transport.send(this.cfg.host + EVENT_PATH, body, {
beacon: useBeacon,
token,
ingestKey: key,
debug: this.cfg.debug,
})
}
// ── internals ────────────────────────────────────────────────────────────
/** sendError frames one exception as a Sentry envelope and posts it to the DSN's
* ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
* server trusts, and the only one a headerless beacon can carry), so NO bearer
* or publishable key is attached here — the two planes authenticate
* independently. Errors are sent one envelope per event, immediately: batching
* a crash report is how you lose it. */
private sendError(err: unknown, options?: CaptureErrorOptions): void {
if (!this.cfg.enabled || !this.dsn) return
const event = buildSentryEvent({
error: err,
options,
identity: this.errorIdentity(),
capturePII: this.cfg.capturePII ?? false,
})
const body = buildEnvelope(event, this.dsn)
if (this.cfg.debug) console.debug('[event] error →', this.dsn.ingestUrl, event.event_id)
this.transport.send(this.dsn.ingestUrl, body, {
beacon: false,
contentType: ENVELOPE_CONTENT_TYPE,
debug: this.cfg.debug,
})
}
/** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
* once identify() has run, else the anon id. Never email/PII. */
private errorIdentity(): ErrorIdentity {
return {
userId: this.personId ?? anonId(),
sessionId: sessionId(),
product: this.cfg.product,
release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
environment: this.cfg.environment ?? readEnv('NODE_ENV'),
}
}
private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {
if (!this.cfg.enabled) return
if (!this.started) this.init()
this.queue.push(this.build(kind, event, extra))
if (this.queue.length >= this.cfg.batchSize) this.flush()
else this.schedule()
}
private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
const anon = anonId()
const wire: WireEvent = {
messageId: uuidv7(),
type: kind,
event,
timestamp: new Date().toISOString(),
distinctId: this.personId ?? anon,
anonymousId: anon,
personId: this.personId,
sessionId: sessionId(),
product: this.cfg.product,
referrer: this.attribution.referrer,
utm: this.attribution.utm,
refCode: this.cohort.refCode ?? this.attribution.refCode,
channel: this.cohort.channel ?? this.attribution.channel,
signupWeek: this.cohort.signupWeek,
// Where the event happened, stamped for every kind rather than only for
// pageviews. An interaction is reported against the page it happened on,
// and autocapture ($click/$input/$change) reaches the wire through
// capture(), which supplies no location — so every click arrived with an
// empty url and path, and `host`, derived from url, was empty with them.
// That is unattributable to a page, which is the one thing a heatmap
// needs. Read here, at the single point every event is built, so it
// cannot go missing from a call site again.
//
// Before `...extra`: pageview() passes an explicit path for route
// changes that fire before window.location has caught up, and that value
// has to win over the one read here.
url: isBrowser() ? window.location.href : undefined,
path: isBrowser() ? window.location.pathname : undefined,
library: '@hanzo/event',
libraryVersion: VERSION,
...extra,
}
// A location is free text an attacker (or an ordinary product flow) controls,
// and it is now stamped on EVERY event rather than only pageviews — so the
// one field that is always a URL gets the same policy the error plane has
// always applied to error text. A password-reset, invite or magic link puts
// a JWT in the query and an address in `?email=`; without this, one click on
// that page ships both to the warehouse in cleartext, and every later click
// repeats it.
//
// AFTER `...extra`, deliberately. A call site can pass its own location —
// pageview() passes `path`, and until this commit it passed `url` too — and
// `extra` merges over the fields read above, so scrubbing at the read would
// have left the highest-volume event emitting a raw location while looking
// scrubbed. Applied to the ASSEMBLED record, the guarantee holds for every
// call site, including ones not written yet.
//
// `scrubText` is the SAME policy as the error plane (secrets always,
// PII unless capturePII) rather than a second URL-specific redactor: one
// definition of "must not leave the browser", already tested, mirroring the
// server's. Guarded on presence so an absent field stays absent instead of
// becoming the empty string that `host` derivation reads as a page.
const capturePII = this.cfg.capturePII ?? false
if (wire.url) wire.url = scrubText(wire.url, capturePII)
if (wire.path) wire.path = scrubText(wire.path, capturePII)
if (wire.referrer) wire.referrer = scrubText(wire.referrer, capturePII)
return wire
}
private schedule(): void {
if (this.timer || !this.cfg.enabled) return
this.timer = setTimeout(() => {
this.timer = null
this.flush()
}, this.cfg.flushIntervalMs)
}
private clearTimer(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
}
// ── one stream, one client ──────────────────────────────────────────────────
//
// A page has ONE event stream per (host, product): one queue, one anon id, one
// credential. Two clients on it split the batch and double the pageview, and
// the half built by the caller that had no key is refused at the edge — so the
// same page is both over- and under-counted, and neither number says so.
//
// Two callers ask for that stream on any real page and both are right to: the
// app mounts a provider, and the component library mounts its own telemetry.
// Neither can see the other, so the fix cannot live in either. It lives here,
// where the stream is: asking twice returns the same client.
//
// The registry is on the PAGE, under a `Symbol.for` slot, not in module scope.
// `@hanzo/event` and `@hanzo/event/react` are separate bundles that each carry
// a copy of this module, so a module-scope map is per-copy and the two copies
// never see each other's — which is exactly how the duplicate arises. A
// well-known symbol is the one slot every copy resolves to the same value.
//
// Browser only. A server process serves many visitors, and one client shared
// across requests would carry one visitor's personId into the next.
const CLIENTS = Symbol.for('hanzo.event.clients')
function registry(): Map<string, Analytics> {
const g = globalThis as unknown as Record<symbol, Map<string, Analytics> | undefined>
const existing = g[CLIENTS]
if (existing) return existing
const fresh = new Map<string, Analytics>()
g[CLIENTS] = fresh
return fresh
}
/** resetClients drops the page's clients, so the next `createAnalytics` for a
* stream builds a fresh one.
*
* A real page loads once and never needs this. A TEST RUNNER is what it exists
* for: one document, many mounts, each meant to be its own page. The second one
* gets a client that already counted this location, so its pageview is
* correctly suppressed and the suite reads it as "nothing was emitted".
*
* It is exported so a suite calls this instead of reaching for the slot itself.
* A consumer that deletes the registry from its own code path takes the
* guarantee with it: `<Provider/>` unmount/remount is not a new page, and the
* fresh client it would get counts the same view again. */
export function resetClients(): void {
const g = globalThis as unknown as Record<symbol, Map<string, Analytics> | undefined>
delete g[CLIENTS]
}
/** createAnalytics returns the client for a stream, building it on first ask.
* This is the ONE way to get a client: `new Analytics` bypasses the registry
* and is for tests and for a deliberately separate instance. */
export function createAnalytics(config: AnalyticsConfig): Analytics {
// A disabled client emits nothing, so it can double nothing and does not take
// the stream. Callers reach their answer at different moments — a provider
// whose ownership resolves in an effect renders once saying `false` before it
// has decided anything — and the client built in that moment must not become
// the one the page keeps. Registering it would silence the page, and adopting
// `enabled` from a later caller would let one surface overrule another's
// reading of consent. Neither: it is simply not the stream's client.
if (!isBrowser() || config.enabled === false) return new Analytics(config)
const key = (config.host ?? DEFAULT_HOST) + '\0' + config.product
const clients = registry()
const existing = clients.get(key)
if (existing) {
existing.adopt(config)
return existing
}
const fresh = new Analytics(config)
clients.set(key, fresh)
return fresh
}
// Re-export the hydrate helpers so consumers can read persisted cohort/attribution
// (e.g. to send refCode to the referrals API) without reaching into storage.
export { getCohort, getFirstTouch }