@hanzo/event 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.mjs CHANGED
@@ -158,9 +158,12 @@ function mergeCohort(patch) {
158
158
  }
159
159
 
160
160
  // src/core.ts
161
- var VERSION = "0.2.0";
162
- var ANALYTICS_PATH = "/v1/analytics";
163
- var TRACKER_PATH = "/v1/tracker";
161
+ var VERSION = "0.3.0";
162
+ var EVENT_PATH = "/v1/event";
163
+ var DEFAULT_HOST = "https://api.hanzo.ai";
164
+ function appendQuery(url, key, value) {
165
+ return url + (url.includes("?") ? "&" : "?") + key + "=" + encodeURIComponent(value);
166
+ }
164
167
  function uid2() {
165
168
  const c = typeof crypto !== "undefined" ? crypto : void 0;
166
169
  if (c && "randomUUID" in c) return c.randomUUID();
@@ -181,15 +184,17 @@ var isBrowser = () => typeof window !== "undefined";
181
184
  var DefaultTransport = class {
182
185
  send(url, body, opts) {
183
186
  if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === "function") {
187
+ const beaconUrl = opts.ingestKey ? appendQuery(url, "ingest_key", opts.ingestKey) : url;
184
188
  try {
185
- navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
189
+ navigator.sendBeacon(beaconUrl, new Blob([body], { type: "application/json" }));
186
190
  return;
187
191
  } catch {
188
192
  }
189
193
  }
190
194
  if (typeof fetch !== "function") return;
191
195
  const headers = { "Content-Type": "application/json" };
192
- if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
196
+ const bearer = opts.ingestKey ?? opts.token;
197
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
193
198
  void fetch(url, {
194
199
  method: "POST",
195
200
  headers,
@@ -212,7 +217,7 @@ var Analytics = class {
212
217
  /** captureException — @sentry-familiar alias of captureError. */
213
218
  this.captureException = this.captureError.bind(this);
214
219
  this.cfg = {
215
- host: "",
220
+ host: DEFAULT_HOST,
216
221
  batchSize: 20,
217
222
  flushIntervalMs: 5e3,
218
223
  enabled: true,
@@ -222,8 +227,9 @@ var Analytics = class {
222
227
  this.transport = config.transport ?? new DefaultTransport();
223
228
  }
224
229
  /** init is idempotent and browser-only for its side effects: capture first-touch
225
- * attribution, hydrate cohort, and register the unload flush. Safe to call from
226
- * a React effect on every render. */
230
+ * attribution, hydrate cohort, register the unload flush, and (unless opted out)
231
+ * auto-capture unhandled errors. Safe to call from a React effect on every
232
+ * render. */
227
233
  init() {
228
234
  if (this.started || !this.cfg.enabled) return;
229
235
  this.started = true;
@@ -270,11 +276,12 @@ var Analytics = class {
270
276
  this.enqueue("event", event, { properties, ...commerce });
271
277
  }
272
278
  /** captureError records an exception as a first-class error event — the ONE
273
- * error path (subsumes @sentry). A caught error, an unhandled rejection, or a
274
- * manual report all become a type:'error' event on the same stream, lensed to
275
- * the error-tracking view server-side. Never throws back into the app; errors
276
- * are higher-signal than pageviews, so it flushes promptly (a crash may unload
277
- * the page moments later). */
279
+ * error path (subsumes @sentry). A caught error, an unhandled rejection, a
280
+ * React render error, or a manual report all become a type:'error' event on the
281
+ * same stream; Cloud folds the exception into properties.$exception and stamps
282
+ * event_type='error', so it surfaces in the error-tracking lens. Never throws
283
+ * back into the app; errors are higher-signal than pageviews, so it flushes
284
+ * promptly (a crash may unload the page moments later). */
278
285
  captureError(err, context) {
279
286
  const ex = normalizeError(err);
280
287
  ex.handled = context?.handled ?? true;
@@ -286,19 +293,28 @@ var Analytics = class {
286
293
  setCohort(patch) {
287
294
  this.cohort = mergeCohort(patch);
288
295
  }
289
- /** flush drains the buffer to the server as one batch. beacon=true uses the
290
- * unload-safe path. */
296
+ /** flush drains the buffer to the server as ONE batch through the ONE ingest
297
+ * front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the
298
+ * unload-safe transport. Auth is orthogonal to the wire:
299
+ *
300
+ * • publishable key set → rides Authorization: Bearer pk_… (fetch) or
301
+ * ?ingest_key=pk_… (beacon), so unload beacons work anonymously.
302
+ * • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
303
+ * carry a header, so token apps fall back to keepalive fetch on unload).
304
+ * • else a cookie app rides same-origin credentials (beacon carries the
305
+ * cookie fine).
306
+ */
291
307
  flush(beacon = false) {
292
308
  if (!this.cfg.enabled || this.queue.length === 0) return;
293
309
  const batch = this.queue;
294
310
  this.queue = [];
295
311
  this.clearTimer();
296
- const token = this.cfg.getToken?.() ?? void 0;
312
+ const key = this.cfg.ingestKey?.trim() || void 0;
313
+ const token = key ? void 0 : this.cfg.getToken?.() ?? void 0;
297
314
  const useBeacon = beacon && !token;
298
- const path = useBeacon ? TRACKER_PATH : ANALYTICS_PATH;
299
315
  const body = JSON.stringify({ batch });
300
- if (this.cfg.debug) console.debug("[analytics] flush", batch.length, path);
301
- this.transport.send(this.cfg.host + path, body, { beacon: useBeacon, token: token ?? void 0 });
316
+ if (this.cfg.debug) console.debug("[event] flush \u2192", EVENT_PATH, batch.length);
317
+ this.transport.send(this.cfg.host + EVENT_PATH, body, { beacon: useBeacon, token, ingestKey: key });
302
318
  }
303
319
  // ── internals ────────────────────────────────────────────────────────────
304
320
  enqueue(kind, event, extra) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/attribution.ts","../src/events.ts","../src/storage.ts","../src/core.ts","../src/react.tsx"],"names":["uid"],"mappings":"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","file":"react.mjs","sourcesContent":["// Pure attribution helpers: parse first-touch UTM/referrer/refCode from a URL,\n// derive the acquisition channel, and compute the ISO week for the signup cohort.\n// No I/O, no globals — trivially testable.\n\nimport type { Attribution } from './types'\n\nconst SOCIAL_HOSTS = [\n 'facebook.', 'instagram.', 'twitter.', 'x.com', 't.co', 'linkedin.',\n 'reddit.', 'youtube.', 'tiktok.', 'pinterest.', 'news.ycombinator.com',\n]\nconst SEARCH_HOSTS = ['google.', 'bing.', 'duckduckgo.', 'yahoo.', 'baidu.', 'ecosia.']\n\n/** parseAttribution reads UTM params + ref/refCode from a query string and pairs\n * them with the referrer. `search` is a location.search value (\"?utm_source=…\"). */\nexport function parseAttribution(search: string, referrer: string): Attribution {\n const q = new URLSearchParams(search || '')\n const get = (k: string) => {\n const v = q.get(k)\n return v ? v.trim() : undefined\n }\n const a: Attribution = {\n utm: {\n source: get('utm_source'),\n medium: get('utm_medium'),\n campaign: get('utm_campaign'),\n term: get('utm_term'),\n content: get('utm_content'),\n },\n referrer: referrer ? referrer.trim() : undefined,\n refCode: get('ref') || get('refCode') || get('ref_code') || undefined,\n }\n a.channel = deriveChannel(a)\n return a\n}\n\n/** deriveChannel classifies the visit: paid | referral | social | organic | direct. */\nexport function deriveChannel(a: Attribution): string {\n const medium = (a.utm.medium || '').toLowerCase()\n if (/(cpc|ppc|paid|paidsearch|display|cpm)/.test(medium)) return 'paid'\n if (a.utm.source || a.utm.campaign) return 'campaign'\n if (a.refCode) return 'referral'\n const host = hostOf(a.referrer)\n if (!host) return 'direct'\n if (SOCIAL_HOSTS.some((h) => host.includes(h))) return 'social'\n if (SEARCH_HOSTS.some((h) => host.includes(h))) return 'organic'\n return 'referral'\n}\n\n/** hostOf extracts a bare lowercase host from a URL; \"\" when unparseable. */\nexport function hostOf(raw?: string): string {\n if (!raw) return ''\n let s = raw.trim()\n const scheme = s.indexOf('://')\n if (scheme >= 0) s = s.slice(scheme + 3)\n const cut = s.search(/[/?#]/)\n if (cut >= 0) s = s.slice(0, cut)\n const at = s.indexOf('@')\n if (at >= 0) s = s.slice(at + 1)\n const colon = s.indexOf(':')\n if (colon >= 0) s = s.slice(0, colon)\n return s.toLowerCase().trim()\n}\n\n/** hasAttribution reports whether anything was captured (so we don't persist an\n * empty first-touch that would shadow a later real one). */\nexport function hasAttribution(a: Attribution): boolean {\n return Boolean(\n a.utm.source || a.utm.medium || a.utm.campaign || a.utm.term ||\n a.utm.content || a.refCode || (a.referrer && hostOf(a.referrer)),\n )\n}\n\n/** isoWeek returns the ISO-8601 week label, e.g. \"2026-W28\". */\nexport function isoWeek(d: Date): string {\n // Copy to UTC midnight; ISO week: Thursday-anchored.\n const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))\n const day = date.getUTCDay() || 7\n date.setUTCDate(date.getUTCDate() + 4 - day)\n const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))\n const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)\n return `${date.getUTCFullYear()}-W${String(week).padStart(2, '0')}`\n}\n","// The ONE product-analytics vocabulary. Every Hanzo surface emits these exact\n// names so funnels, goals, and cohorts line up across console/chat/app/site/admin.\n// Pageviews use the reserved \"$pageview\" name (emitted by analytics.pageview()),\n// matching the server read lens.\n\nexport const EVENTS = {\n // Signup funnel: view -> submit -> verify -> completed -> first action.\n SIGNUP_VIEWED: 'signup_viewed',\n SIGNUP_SUBMITTED: 'signup_submitted',\n SIGNUP_VERIFIED: 'signup_verified',\n SIGNUP_COMPLETED: 'signup_completed',\n FIRST_ACTION: 'first_action',\n\n // Waitlist + referral.\n WAITLIST_JOINED: 'waitlist_joined',\n WAITLIST_SHARED: 'waitlist_shared',\n REFERRAL_USED: 'referral_used',\n REFERRAL_CLAIMED: 'referral_claimed',\n\n // Upgrade-intent + purchase.\n PRICING_VIEWED: 'pricing_viewed',\n PLAN_CLICKED: 'plan_clicked',\n CHECKOUT_STARTED: 'checkout_started',\n ORDER_COMPLETED: 'order_completed',\n\n // Feature usage — generic + the common key surfaces across products.\n FEATURE_USED: 'feature_used',\n API_KEY_CREATED: 'api_key_created',\n APP_CREATED: 'app_created',\n DEPLOY_STARTED: 'deploy_started',\n PROJECT_CREATED: 'project_created',\n AGENT_CREATED: 'agent_created',\n CHAT_STARTED: 'chat_started',\n CHAT_MESSAGE_SENT: 'chat_message_sent',\n TASK_STARTED: 'task_started',\n TASK_COMPLETED: 'task_completed',\n} as const\n\nexport type EventName = (typeof EVENTS)[keyof typeof EVENTS]\n\n/** The reserved event name a pageview is stored under (server + read lens). */\nexport const PAGEVIEW = '$pageview'\n","// SSR-safe browser storage for stable identifiers and first-touch state. Every\n// accessor no-ops (returns undefined) when there is no window/localStorage, so the\n// client imports cleanly in a Next.js server component.\n\nimport type { Attribution, Cohort } from './types'\n\nconst KEY = {\n anon: 'hz_anon_id',\n session: 'hz_session',\n firstTouch: 'hz_first_touch',\n cohort: 'hz_cohort',\n} as const\n\n/** 30-minute inactivity window defines a session (PostHog/GA convention). */\nconst SESSION_TTL_MS = 30 * 60 * 1000\n\nfunction ls(): Storage | undefined {\n try {\n if (typeof window === 'undefined' || !window.localStorage) return undefined\n return window.localStorage\n } catch {\n return undefined // Safari private mode / blocked storage\n }\n}\n\nfunction uid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined\n if (c && 'randomUUID' in c) return c.randomUUID()\n return 'a-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)\n}\n\n/** Stable anonymous id, minted once per browser and reused across sessions. */\nexport function anonId(): string | undefined {\n const s = ls()\n if (!s) return undefined\n let v = s.getItem(KEY.anon)\n if (!v) {\n v = uid()\n s.setItem(KEY.anon, v)\n }\n return v\n}\n\ninterface SessionState {\n id: string\n last: number\n}\n\n/** Current session id, rotated after SESSION_TTL_MS of inactivity. */\nexport function sessionId(now = Date.now()): string | undefined {\n const s = ls()\n if (!s) return undefined\n let state: SessionState | null = null\n try {\n state = JSON.parse(s.getItem(KEY.session) || 'null')\n } catch {\n state = null\n }\n if (!state || now - state.last > SESSION_TTL_MS) {\n state = { id: uid(), last: now }\n } else {\n state.last = now\n }\n s.setItem(KEY.session, JSON.stringify(state))\n return state.id\n}\n\n/** Read the persisted first-touch attribution. */\nexport function getFirstTouch(): Attribution | undefined {\n const s = ls()\n if (!s) return undefined\n try {\n const v = s.getItem(KEY.firstTouch)\n return v ? (JSON.parse(v) as Attribution) : undefined\n } catch {\n return undefined\n }\n}\n\n/** Persist first-touch attribution ONCE — never overwrite an existing record. */\nexport function setFirstTouchOnce(a: Attribution): Attribution {\n const s = ls()\n const existing = getFirstTouch()\n if (existing) return existing\n if (s) s.setItem(KEY.firstTouch, JSON.stringify(a))\n return a\n}\n\n/** Read persisted cohort dimensions. */\nexport function getCohort(): Cohort | undefined {\n const s = ls()\n if (!s) return undefined\n try {\n const v = s.getItem(KEY.cohort)\n return v ? (JSON.parse(v) as Cohort) : undefined\n } catch {\n return undefined\n }\n}\n\n/** Merge + persist cohort dimensions (signupWeek set once). */\nexport function mergeCohort(patch: Cohort): Cohort {\n const s = ls()\n const cur = getCohort() || {}\n const next: Cohort = {\n signupWeek: cur.signupWeek || patch.signupWeek,\n channel: patch.channel || cur.channel,\n refCode: cur.refCode || patch.refCode,\n }\n if (s) s.setItem(KEY.cohort, JSON.stringify(next))\n return next\n}\n","// The framework-agnostic event client. Buffers events and flushes them as one\n// batch to Hanzo Cloud — /v1/analytics normally, /v1/tracker via sendBeacon on\n// page unload. It NEVER sends the org/tenant: the server stamps that from the\n// validated session. The client only supplies its own visitor identity. Errors\n// are just events (type:'error') on the same stream — one client, one pipe.\n\nimport {\n parseAttribution,\n hasAttribution,\n deriveChannel,\n} from './attribution'\nimport { PAGEVIEW } from './events'\nimport {\n anonId,\n sessionId,\n getFirstTouch,\n setFirstTouchOnce,\n getCohort,\n mergeCohort,\n} from './storage'\nimport type {\n AnalyticsConfig,\n Attribution,\n Cohort,\n EventKind,\n Exception,\n Transport,\n WireEvent,\n} from './types'\n\nexport const VERSION = '0.2.0'\n\nconst ANALYTICS_PATH = '/v1/analytics'\nconst TRACKER_PATH = '/v1/tracker' // beacon-on-unload alias\n\nfunction uid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined\n if (c && 'randomUUID' in c) return c.randomUUID()\n return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)\n}\n\n/** Normalize anything thrown (Error | string | unknown) into an Exception. */\nfunction normalizeError(err: unknown): Exception {\n if (err instanceof Error) {\n return { type: err.name, message: err.message, stack: err.stack }\n }\n if (typeof err === 'string') return { message: err }\n try {\n return { message: JSON.stringify(err) }\n } catch {\n return { message: String(err) }\n }\n}\n\nconst isBrowser = () => typeof window !== 'undefined'\n\n/** DefaultTransport: fetch(keepalive) for authenticated/normal sends;\n * navigator.sendBeacon for headerless page-unload beacons. */\nclass DefaultTransport implements Transport {\n send(url: string, body: string, opts: { beacon: boolean; token?: string }): void {\n if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {\n try {\n navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))\n return\n } catch {\n /* fall through to fetch */\n }\n }\n if (typeof fetch !== 'function') return\n const headers: Record<string, string> = { 'Content-Type': 'application/json' }\n if (opts.token) headers.Authorization = `Bearer ${opts.token}`\n void fetch(url, {\n method: 'POST',\n headers,\n body,\n keepalive: true,\n credentials: 'include',\n }).catch(() => {\n /* analytics loss is acceptable; never throw into the app */\n })\n }\n}\n\nexport class Analytics {\n private cfg: Required<\n Pick<AnalyticsConfig, 'product' | 'batchSize' | 'flushIntervalMs' | 'enabled' | 'captureErrors'>\n > &\n AnalyticsConfig\n private transport: Transport\n private queue: WireEvent[] = []\n private timer: ReturnType<typeof setTimeout> | null = null\n private personId?: string\n private attribution: Attribution = { utm: {} }\n private cohort: Cohort = {}\n private started = false\n\n constructor(config: AnalyticsConfig) {\n this.cfg = {\n host: '',\n batchSize: 20,\n flushIntervalMs: 5000,\n enabled: true,\n captureErrors: true,\n ...config,\n }\n this.transport = config.transport ?? new DefaultTransport()\n }\n\n /** init is idempotent and browser-only for its side effects: capture first-touch\n * attribution, hydrate cohort, and register the unload flush. Safe to call from\n * a React effect on every render. */\n init(): void {\n if (this.started || !this.cfg.enabled) return\n this.started = true\n if (!isBrowser()) return\n\n const parsed = parseAttribution(window.location.search, document.referrer)\n this.attribution = hasAttribution(parsed)\n ? setFirstTouchOnce(parsed)\n : getFirstTouch() ?? parsed\n this.cohort = mergeCohort({\n channel: this.attribution.channel ?? deriveChannel(this.attribution),\n refCode: this.attribution.refCode,\n })\n\n const flushHidden = () => {\n if (document.visibilityState === 'hidden') this.flush(true)\n }\n window.addEventListener('visibilitychange', flushHidden)\n window.addEventListener('pagehide', () => this.flush(true))\n\n // Auto error capture — the drop-in @sentry replacement. Unhandled errors and\n // rejected promises become type:'error' events on the same stream.\n if (this.cfg.captureErrors) {\n window.addEventListener('error', (e: ErrorEvent) => {\n this.captureError(e.error ?? e.message, { handled: false })\n })\n window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {\n this.captureError(e.reason, { handled: false })\n })\n }\n }\n\n /** identify binds the current visitor to a stable person id (post-login). */\n identify(personId: string, traits?: Record<string, unknown>): void {\n this.personId = personId\n this.enqueue('identify', undefined, { properties: traits })\n }\n\n /** group associates the visitor with an org/team (analytics grouping, not the\n * server tenant — the server still derives tenant from the session). */\n group(groupId: string, traits?: Record<string, unknown>): void {\n this.enqueue('group', undefined, { groupId, properties: traits })\n }\n\n /** pageview records a $pageview for the current (or given) location. */\n pageview(path?: string, properties?: Record<string, unknown>): void {\n const url = isBrowser() ? window.location.href : undefined\n const p = path ?? (isBrowser() ? window.location.pathname : undefined)\n this.enqueue('pageview', PAGEVIEW, { url, path: p, properties })\n }\n\n /** capture records a named product event with optional properties. Commerce\n * fields (productId/quantity/revenue/currency) may be passed for order events. */\n capture(\n event: string,\n properties?: Record<string, unknown>,\n commerce?: Pick<WireEvent, 'productId' | 'quantity' | 'revenue' | 'currency'>,\n ): void {\n this.enqueue('event', event, { properties, ...commerce })\n }\n\n /** track is an alias of capture (Segment familiarity). */\n track = this.capture.bind(this)\n\n /** captureError records an exception as a first-class error event — the ONE\n * error path (subsumes @sentry). A caught error, an unhandled rejection, or a\n * manual report all become a type:'error' event on the same stream, lensed to\n * the error-tracking view server-side. Never throws back into the app; errors\n * are higher-signal than pageviews, so it flushes promptly (a crash may unload\n * the page moments later). */\n captureError(\n err: unknown,\n context?: { handled?: boolean; properties?: Record<string, unknown> },\n ): void {\n const ex = normalizeError(err)\n ex.handled = context?.handled ?? true\n this.enqueue('error', ex.message, { error: ex, properties: context?.properties })\n this.flush()\n }\n\n /** captureException — @sentry-familiar alias of captureError. */\n captureException = this.captureError.bind(this)\n\n /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride\n * every subsequent event. */\n setCohort(patch: Cohort): void {\n this.cohort = mergeCohort(patch)\n }\n\n /** flush drains the buffer to the server as one batch. beacon=true uses the\n * unload-safe path. */\n flush(beacon = false): void {\n if (!this.cfg.enabled || this.queue.length === 0) return\n const batch = this.queue\n this.queue = []\n this.clearTimer()\n const token = this.cfg.getToken?.() ?? undefined\n // sendBeacon cannot carry an Authorization header, so token apps always use\n // keepalive fetch; cookie apps may beacon to the tracker route on unload.\n const useBeacon = beacon && !token\n const path = useBeacon ? TRACKER_PATH : ANALYTICS_PATH\n const body = JSON.stringify({ batch })\n if (this.cfg.debug) console.debug('[analytics] flush', batch.length, path)\n this.transport.send(this.cfg.host + path, body, { beacon: useBeacon, token: token ?? undefined })\n }\n\n // ── internals ────────────────────────────────────────────────────────────\n\n private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {\n if (!this.cfg.enabled) return\n if (!this.started) this.init()\n this.queue.push(this.build(kind, event, extra))\n if (this.queue.length >= this.cfg.batchSize) this.flush()\n else this.schedule()\n }\n\n private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {\n const anon = anonId()\n return {\n messageId: uid(),\n type: kind,\n event,\n timestamp: new Date().toISOString(),\n distinctId: this.personId ?? anon,\n anonymousId: anon,\n personId: this.personId,\n sessionId: sessionId(),\n product: this.cfg.product,\n referrer: this.attribution.referrer,\n utm: this.attribution.utm,\n refCode: this.cohort.refCode ?? this.attribution.refCode,\n channel: this.cohort.channel ?? this.attribution.channel,\n signupWeek: this.cohort.signupWeek,\n library: '@hanzo/event',\n libraryVersion: VERSION,\n ...extra,\n }\n }\n\n private schedule(): void {\n if (this.timer || !this.cfg.enabled) return\n this.timer = setTimeout(() => {\n this.timer = null\n this.flush()\n }, this.cfg.flushIntervalMs)\n }\n\n private clearTimer(): void {\n if (this.timer) {\n clearTimeout(this.timer)\n this.timer = null\n }\n }\n}\n\n/** createAnalytics builds a client instance. Most apps use one shared instance. */\nexport function createAnalytics(config: AnalyticsConfig): Analytics {\n return new Analytics(config)\n}\n\n// Re-export the hydrate helpers so consumers can read persisted cohort/attribution\n// (e.g. to send refCode to the referrals API) without reaching into storage.\nexport { getCohort, getFirstTouch }\n","// React bindings for @hanzo/event — a provider, an accessor hook, a route-change\n// pageview hook, and an error boundary. Framework-neutral: it takes the current\n// path as an argument (the Next app wires usePathname(); a Vite/router app passes\n// its own), so this file never imports next/*.\n//\n// 'use client'\n// import { AnalyticsProvider, useAnalytics, usePageview, ErrorBoundary } from '@hanzo/event/react'\n//\n// <AnalyticsProvider config={{ product: 'console' }}>…</AnalyticsProvider>\n// const a = useAnalytics(); usePageview(usePathname())\n\nimport {\n Component,\n createContext,\n createElement,\n useContext,\n useEffect,\n useMemo,\n useRef,\n type ErrorInfo,\n type ReactNode,\n} from 'react'\nimport { Analytics, createAnalytics } from './core'\nimport type { AnalyticsConfig } from './types'\n\nconst Ctx = createContext<Analytics | null>(null)\n\nexport interface AnalyticsProviderProps {\n /** Provide a pre-built client, or a config to build one. */\n client?: Analytics\n config?: AnalyticsConfig\n /** Fire a pageview for the initial load (default true). */\n autoPageview?: boolean\n children: ReactNode\n}\n\nexport function AnalyticsProvider(props: AnalyticsProviderProps) {\n const { client, config, autoPageview = true, children } = props\n const instance = useMemo<Analytics>(() => {\n if (client) return client\n if (config) return createAnalytics(config)\n throw new Error('AnalyticsProvider requires `client` or `config`')\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client])\n\n useEffect(() => {\n instance.init()\n if (autoPageview) instance.pageview()\n }, [instance, autoPageview])\n\n return createElement(Ctx.Provider, { value: instance }, children)\n}\n\n/** useAnalytics returns the client. Outside a provider it returns a no-op-safe\n * null-guarded proxy so calls never throw during SSR/tests. */\nexport function useAnalytics(): Analytics {\n const a = useContext(Ctx)\n if (!a) return noop\n return a\n}\n\n/** usePageview fires a pageview on every route CHANGE (not the initial mount —\n * the provider's autoPageview covers that, so pages are counted exactly once). */\nexport function usePageview(path: string | null | undefined): void {\n const a = useContext(Ctx)\n const first = useRef(true)\n useEffect(() => {\n if (first.current) {\n first.current = false\n return\n }\n if (a && path) a.pageview(path)\n }, [a, path])\n}\n\n// A shared no-op client for use outside a provider — keeps call sites total.\nconst noop = new Analytics({ product: 'unknown', enabled: false })\n\nexport interface ErrorBoundaryProps {\n children: ReactNode\n /** Rendered when a child throws. A function receives the error + a reset. */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)\n /** Explicit client; defaults to the one from AnalyticsProvider. */\n client?: Analytics\n}\n\ninterface ErrorBoundaryState {\n error: Error | null\n}\n\n/** ErrorBoundary reports React render errors to the client as error events —\n * React swallows these before window.onerror sees them, so a boundary is the\n * ONLY way to capture them. This is the React half of the @sentry replacement.\n * Pair it with AnalyticsProvider (it reads the client from context), or pass an\n * explicit `client`. */\nexport class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {\n static contextType = Ctx\n declare context: React.ContextType<typeof Ctx>\n state: ErrorBoundaryState = { error: null }\n\n static getDerivedStateFromError(error: Error): ErrorBoundaryState {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo): void {\n const client = this.props.client ?? this.context ?? undefined\n client?.captureError(error, {\n handled: false,\n properties: { componentStack: info.componentStack, react: true },\n })\n }\n\n private reset = () => this.setState({ error: null })\n\n render(): ReactNode {\n const { error } = this.state\n if (error === null) return this.props.children\n const { fallback } = this.props\n if (typeof fallback === 'function') return fallback(error, this.reset)\n return fallback ?? null\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/attribution.ts","../src/events.ts","../src/storage.ts","../src/core.ts","../src/react.tsx"],"names":["uid"],"mappings":"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","file":"react.mjs","sourcesContent":["// Pure attribution helpers: parse first-touch UTM/referrer/refCode from a URL,\n// derive the acquisition channel, and compute the ISO week for the signup cohort.\n// No I/O, no globals — trivially testable.\n\nimport type { Attribution } from './types'\n\nconst SOCIAL_HOSTS = [\n 'facebook.', 'instagram.', 'twitter.', 'x.com', 't.co', 'linkedin.',\n 'reddit.', 'youtube.', 'tiktok.', 'pinterest.', 'news.ycombinator.com',\n]\nconst SEARCH_HOSTS = ['google.', 'bing.', 'duckduckgo.', 'yahoo.', 'baidu.', 'ecosia.']\n\n/** parseAttribution reads UTM params + ref/refCode from a query string and pairs\n * them with the referrer. `search` is a location.search value (\"?utm_source=…\"). */\nexport function parseAttribution(search: string, referrer: string): Attribution {\n const q = new URLSearchParams(search || '')\n const get = (k: string) => {\n const v = q.get(k)\n return v ? v.trim() : undefined\n }\n const a: Attribution = {\n utm: {\n source: get('utm_source'),\n medium: get('utm_medium'),\n campaign: get('utm_campaign'),\n term: get('utm_term'),\n content: get('utm_content'),\n },\n referrer: referrer ? referrer.trim() : undefined,\n refCode: get('ref') || get('refCode') || get('ref_code') || undefined,\n }\n a.channel = deriveChannel(a)\n return a\n}\n\n/** deriveChannel classifies the visit: paid | referral | social | organic | direct. */\nexport function deriveChannel(a: Attribution): string {\n const medium = (a.utm.medium || '').toLowerCase()\n if (/(cpc|ppc|paid|paidsearch|display|cpm)/.test(medium)) return 'paid'\n if (a.utm.source || a.utm.campaign) return 'campaign'\n if (a.refCode) return 'referral'\n const host = hostOf(a.referrer)\n if (!host) return 'direct'\n if (SOCIAL_HOSTS.some((h) => host.includes(h))) return 'social'\n if (SEARCH_HOSTS.some((h) => host.includes(h))) return 'organic'\n return 'referral'\n}\n\n/** hostOf extracts a bare lowercase host from a URL; \"\" when unparseable. */\nexport function hostOf(raw?: string): string {\n if (!raw) return ''\n let s = raw.trim()\n const scheme = s.indexOf('://')\n if (scheme >= 0) s = s.slice(scheme + 3)\n const cut = s.search(/[/?#]/)\n if (cut >= 0) s = s.slice(0, cut)\n const at = s.indexOf('@')\n if (at >= 0) s = s.slice(at + 1)\n const colon = s.indexOf(':')\n if (colon >= 0) s = s.slice(0, colon)\n return s.toLowerCase().trim()\n}\n\n/** hasAttribution reports whether anything was captured (so we don't persist an\n * empty first-touch that would shadow a later real one). */\nexport function hasAttribution(a: Attribution): boolean {\n return Boolean(\n a.utm.source || a.utm.medium || a.utm.campaign || a.utm.term ||\n a.utm.content || a.refCode || (a.referrer && hostOf(a.referrer)),\n )\n}\n\n/** isoWeek returns the ISO-8601 week label, e.g. \"2026-W28\". */\nexport function isoWeek(d: Date): string {\n // Copy to UTC midnight; ISO week: Thursday-anchored.\n const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))\n const day = date.getUTCDay() || 7\n date.setUTCDate(date.getUTCDate() + 4 - day)\n const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))\n const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)\n return `${date.getUTCFullYear()}-W${String(week).padStart(2, '0')}`\n}\n","// The ONE product-analytics vocabulary. Every Hanzo surface emits these exact\n// names so funnels, goals, and cohorts line up across console/chat/app/site/admin.\n// Pageviews use the reserved \"$pageview\" name (emitted by analytics.pageview()),\n// matching the server read lens.\n\nexport const EVENTS = {\n // Signup funnel: view -> submit -> verify -> completed -> first action.\n SIGNUP_VIEWED: 'signup_viewed',\n SIGNUP_SUBMITTED: 'signup_submitted',\n SIGNUP_VERIFIED: 'signup_verified',\n SIGNUP_COMPLETED: 'signup_completed',\n FIRST_ACTION: 'first_action',\n\n // Waitlist + referral.\n WAITLIST_JOINED: 'waitlist_joined',\n WAITLIST_SHARED: 'waitlist_shared',\n REFERRAL_USED: 'referral_used',\n REFERRAL_CLAIMED: 'referral_claimed',\n\n // Upgrade-intent + purchase.\n PRICING_VIEWED: 'pricing_viewed',\n PLAN_CLICKED: 'plan_clicked',\n CHECKOUT_STARTED: 'checkout_started',\n ORDER_COMPLETED: 'order_completed',\n\n // Feature usage — generic + the common key surfaces across products.\n FEATURE_USED: 'feature_used',\n API_KEY_CREATED: 'api_key_created',\n APP_CREATED: 'app_created',\n DEPLOY_STARTED: 'deploy_started',\n PROJECT_CREATED: 'project_created',\n AGENT_CREATED: 'agent_created',\n CHAT_STARTED: 'chat_started',\n CHAT_MESSAGE_SENT: 'chat_message_sent',\n TASK_STARTED: 'task_started',\n TASK_COMPLETED: 'task_completed',\n} as const\n\nexport type EventName = (typeof EVENTS)[keyof typeof EVENTS]\n\n/** The reserved event name a pageview is stored under (server + read lens). */\nexport const PAGEVIEW = '$pageview'\n","// SSR-safe browser storage for stable identifiers and first-touch state. Every\n// accessor no-ops (returns undefined) when there is no window/localStorage, so the\n// client imports cleanly in a Next.js server component.\n\nimport type { Attribution, Cohort } from './types'\n\nconst KEY = {\n anon: 'hz_anon_id',\n session: 'hz_session',\n firstTouch: 'hz_first_touch',\n cohort: 'hz_cohort',\n} as const\n\n/** 30-minute inactivity window defines a session (PostHog/GA convention). */\nconst SESSION_TTL_MS = 30 * 60 * 1000\n\nfunction ls(): Storage | undefined {\n try {\n if (typeof window === 'undefined' || !window.localStorage) return undefined\n return window.localStorage\n } catch {\n return undefined // Safari private mode / blocked storage\n }\n}\n\nfunction uid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined\n if (c && 'randomUUID' in c) return c.randomUUID()\n return 'a-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)\n}\n\n/** Stable anonymous id, minted once per browser and reused across sessions. */\nexport function anonId(): string | undefined {\n const s = ls()\n if (!s) return undefined\n let v = s.getItem(KEY.anon)\n if (!v) {\n v = uid()\n s.setItem(KEY.anon, v)\n }\n return v\n}\n\ninterface SessionState {\n id: string\n last: number\n}\n\n/** Current session id, rotated after SESSION_TTL_MS of inactivity. */\nexport function sessionId(now = Date.now()): string | undefined {\n const s = ls()\n if (!s) return undefined\n let state: SessionState | null = null\n try {\n state = JSON.parse(s.getItem(KEY.session) || 'null')\n } catch {\n state = null\n }\n if (!state || now - state.last > SESSION_TTL_MS) {\n state = { id: uid(), last: now }\n } else {\n state.last = now\n }\n s.setItem(KEY.session, JSON.stringify(state))\n return state.id\n}\n\n/** Read the persisted first-touch attribution. */\nexport function getFirstTouch(): Attribution | undefined {\n const s = ls()\n if (!s) return undefined\n try {\n const v = s.getItem(KEY.firstTouch)\n return v ? (JSON.parse(v) as Attribution) : undefined\n } catch {\n return undefined\n }\n}\n\n/** Persist first-touch attribution ONCE — never overwrite an existing record. */\nexport function setFirstTouchOnce(a: Attribution): Attribution {\n const s = ls()\n const existing = getFirstTouch()\n if (existing) return existing\n if (s) s.setItem(KEY.firstTouch, JSON.stringify(a))\n return a\n}\n\n/** Read persisted cohort dimensions. */\nexport function getCohort(): Cohort | undefined {\n const s = ls()\n if (!s) return undefined\n try {\n const v = s.getItem(KEY.cohort)\n return v ? (JSON.parse(v) as Cohort) : undefined\n } catch {\n return undefined\n }\n}\n\n/** Merge + persist cohort dimensions (signupWeek set once). */\nexport function mergeCohort(patch: Cohort): Cohort {\n const s = ls()\n const cur = getCohort() || {}\n const next: Cohort = {\n signupWeek: cur.signupWeek || patch.signupWeek,\n channel: patch.channel || cur.channel,\n refCode: cur.refCode || patch.refCode,\n }\n if (s) s.setItem(KEY.cohort, JSON.stringify(next))\n return next\n}\n","// The framework-agnostic event client. Buffers events and flushes them as ONE\n// batch through the ONE Hanzo Cloud ingestion front door:\n//\n// POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }\n//\n// It NEVER sends the org/tenant: Cloud resolves that server-side (from the\n// validated session, or the signed publishable key) and stamps it. The client\n// only supplies its own visitor identity. Errors are just events (type:'error')\n// on the same stream — one client, one pipe, lensed server-side into product\n// analytics (insights), web analytics (analytics), and error tracking (sentry).\n//\n// Auth is orthogonal — the SAME body to the SAME door, differing only in how the\n// caller proves its tenant:\n//\n// • cookie/session app (host:'') — same-origin credentials ride the request.\n// • bearer app (getToken) — Authorization: Bearer <jwt>.\n// • publishable-key app (ingestKey: 'pk_…') — Authorization: Bearer pk_… on\n// fetch, ?ingest_key=pk_… on a headerless page-unload beacon. Write-only and\n// safe to ship in a bundle; the door HMAC-verifies it to an org server-side.\n//\n// The wire is the canonical `Event` (== the cloud CaptureEvent): its `type` field\n// is what Cloud folds to event_type='error', so a captured exception reaches the\n// error-tracking lens. (A four-field {event,distinctId,time,properties} object has\n// no `type`, so it can never be lensed as an error — this batched Event wire is\n// the one that lights up all three lenses.)\n\nimport {\n parseAttribution,\n hasAttribution,\n deriveChannel,\n} from './attribution'\nimport { PAGEVIEW } from './events'\nimport {\n anonId,\n sessionId,\n getFirstTouch,\n setFirstTouchOnce,\n getCohort,\n mergeCohort,\n} from './storage'\nimport type {\n AnalyticsConfig,\n Attribution,\n Cohort,\n EventKind,\n Exception,\n Transport,\n WireEvent,\n} from './types'\n\nexport const VERSION = '0.3.0'\n\nconst EVENT_PATH = '/v1/event' // the ONE canonical ingestion front door\nconst DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''\n\n/** appendQuery adds a single query param to a URL string — used to carry a\n * publishable key on a headerless sendBeacon (?ingest_key=…). */\nfunction appendQuery(url: string, key: string, value: string): string {\n return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)\n}\n\nfunction uid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined\n if (c && 'randomUUID' in c) return c.randomUUID()\n return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)\n}\n\n/** Normalize anything thrown (Error | string | unknown) into an Exception. */\nfunction normalizeError(err: unknown): Exception {\n if (err instanceof Error) {\n return { type: err.name, message: err.message, stack: err.stack }\n }\n if (typeof err === 'string') return { message: err }\n try {\n return { message: JSON.stringify(err) }\n } catch {\n return { message: String(err) }\n }\n}\n\nconst isBrowser = () => typeof window !== 'undefined'\n\n/** DefaultTransport: fetch(keepalive) for authenticated/normal sends;\n * navigator.sendBeacon for headerless page-unload beacons. A bearer (a JWT or a\n * publishable pk_ key) rides Authorization on fetch; on a beacon — which cannot\n * set headers — a publishable key rides the ?ingest_key query instead. */\nclass DefaultTransport implements Transport {\n send(url: string, body: string, opts: { beacon: boolean; token?: string; ingestKey?: string }): void {\n if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {\n const beaconUrl = opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url\n try {\n navigator.sendBeacon(beaconUrl, new Blob([body], { type: 'application/json' }))\n return\n } catch {\n /* fall through to fetch */\n }\n }\n if (typeof fetch !== 'function') return\n const headers: Record<string, string> = { 'Content-Type': 'application/json' }\n const bearer = opts.ingestKey ?? opts.token\n if (bearer) headers.Authorization = `Bearer ${bearer}`\n void fetch(url, {\n method: 'POST',\n headers,\n body,\n keepalive: true,\n credentials: 'include',\n }).catch(() => {\n /* telemetry loss is acceptable; never throw into the app */\n })\n }\n}\n\nexport class Analytics {\n private cfg: Required<\n Pick<AnalyticsConfig, 'product' | 'batchSize' | 'flushIntervalMs' | 'enabled' | 'captureErrors'>\n > &\n AnalyticsConfig\n private transport: Transport\n private queue: WireEvent[] = []\n private timer: ReturnType<typeof setTimeout> | null = null\n private personId?: string\n private attribution: Attribution = { utm: {} }\n private cohort: Cohort = {}\n private started = false\n\n constructor(config: AnalyticsConfig) {\n this.cfg = {\n host: DEFAULT_HOST,\n batchSize: 20,\n flushIntervalMs: 5000,\n enabled: true,\n captureErrors: true,\n ...config,\n }\n this.transport = config.transport ?? new DefaultTransport()\n }\n\n /** init is idempotent and browser-only for its side effects: capture first-touch\n * attribution, hydrate cohort, register the unload flush, and (unless opted out)\n * auto-capture unhandled errors. Safe to call from a React effect on every\n * render. */\n init(): void {\n if (this.started || !this.cfg.enabled) return\n this.started = true\n if (!isBrowser()) return\n\n const parsed = parseAttribution(window.location.search, document.referrer)\n this.attribution = hasAttribution(parsed)\n ? setFirstTouchOnce(parsed)\n : getFirstTouch() ?? parsed\n this.cohort = mergeCohort({\n channel: this.attribution.channel ?? deriveChannel(this.attribution),\n refCode: this.attribution.refCode,\n })\n\n const flushHidden = () => {\n if (document.visibilityState === 'hidden') this.flush(true)\n }\n window.addEventListener('visibilitychange', flushHidden)\n window.addEventListener('pagehide', () => this.flush(true))\n\n // Auto error capture — the drop-in @sentry replacement. Unhandled errors and\n // rejected promises become type:'error' events on the same stream, which Cloud\n // stamps event_type='error' → the sentry.hanzo.ai lens.\n if (this.cfg.captureErrors) {\n window.addEventListener('error', (e: ErrorEvent) => {\n this.captureError(e.error ?? e.message, { handled: false })\n })\n window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {\n this.captureError(e.reason, { handled: false })\n })\n }\n }\n\n /** identify binds the current visitor to a stable person id (post-login). */\n identify(personId: string, traits?: Record<string, unknown>): void {\n this.personId = personId\n this.enqueue('identify', undefined, { properties: traits })\n }\n\n /** group associates the visitor with an org/team (analytics grouping, not the\n * server tenant — the server still derives tenant from the session). */\n group(groupId: string, traits?: Record<string, unknown>): void {\n this.enqueue('group', undefined, { groupId, properties: traits })\n }\n\n /** pageview records a $pageview for the current (or given) location. */\n pageview(path?: string, properties?: Record<string, unknown>): void {\n const url = isBrowser() ? window.location.href : undefined\n const p = path ?? (isBrowser() ? window.location.pathname : undefined)\n this.enqueue('pageview', PAGEVIEW, { url, path: p, properties })\n }\n\n /** capture records a named product event with optional properties. Commerce\n * fields (productId/quantity/revenue/currency) may be passed for order events. */\n capture(\n event: string,\n properties?: Record<string, unknown>,\n commerce?: Pick<WireEvent, 'productId' | 'quantity' | 'revenue' | 'currency'>,\n ): void {\n this.enqueue('event', event, { properties, ...commerce })\n }\n\n /** track is an alias of capture (Segment familiarity). */\n track = this.capture.bind(this)\n\n /** captureError records an exception as a first-class error event — the ONE\n * error path (subsumes @sentry). A caught error, an unhandled rejection, a\n * React render error, or a manual report all become a type:'error' event on the\n * same stream; Cloud folds the exception into properties.$exception and stamps\n * event_type='error', so it surfaces in the error-tracking lens. Never throws\n * back into the app; errors are higher-signal than pageviews, so it flushes\n * promptly (a crash may unload the page moments later). */\n captureError(\n err: unknown,\n context?: { handled?: boolean; properties?: Record<string, unknown> },\n ): void {\n const ex = normalizeError(err)\n ex.handled = context?.handled ?? true\n this.enqueue('error', ex.message, { error: ex, properties: context?.properties })\n this.flush()\n }\n\n /** captureException — @sentry-familiar alias of captureError. */\n captureException = this.captureError.bind(this)\n\n /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride\n * every subsequent event. */\n setCohort(patch: Cohort): void {\n this.cohort = mergeCohort(patch)\n }\n\n /** flush drains the buffer to the server as ONE batch through the ONE ingest\n * front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the\n * unload-safe transport. Auth is orthogonal to the wire:\n *\n * • publishable key set → rides Authorization: Bearer pk_… (fetch) or\n * ?ingest_key=pk_… (beacon), so unload beacons work anonymously.\n * • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot\n * carry a header, so token apps fall back to keepalive fetch on unload).\n * • else a cookie app rides same-origin credentials (beacon carries the\n * cookie fine).\n */\n flush(beacon = false): void {\n if (!this.cfg.enabled || this.queue.length === 0) return\n const batch = this.queue\n this.queue = []\n this.clearTimer()\n\n const key = this.cfg.ingestKey?.trim() || undefined\n // A publishable key and a bearer JWT are mutually exclusive doors; the key wins.\n const token = key ? undefined : this.cfg.getToken?.() ?? undefined\n // Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an\n // Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.\n const useBeacon = beacon && !token\n const body = JSON.stringify({ batch })\n if (this.cfg.debug) console.debug('[event] flush →', EVENT_PATH, batch.length)\n this.transport.send(this.cfg.host + EVENT_PATH, body, { beacon: useBeacon, token, ingestKey: key })\n }\n\n // ── internals ────────────────────────────────────────────────────────────\n\n private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {\n if (!this.cfg.enabled) return\n if (!this.started) this.init()\n this.queue.push(this.build(kind, event, extra))\n if (this.queue.length >= this.cfg.batchSize) this.flush()\n else this.schedule()\n }\n\n private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {\n const anon = anonId()\n return {\n messageId: uid(),\n type: kind,\n event,\n timestamp: new Date().toISOString(),\n distinctId: this.personId ?? anon,\n anonymousId: anon,\n personId: this.personId,\n sessionId: sessionId(),\n product: this.cfg.product,\n referrer: this.attribution.referrer,\n utm: this.attribution.utm,\n refCode: this.cohort.refCode ?? this.attribution.refCode,\n channel: this.cohort.channel ?? this.attribution.channel,\n signupWeek: this.cohort.signupWeek,\n library: '@hanzo/event',\n libraryVersion: VERSION,\n ...extra,\n }\n }\n\n private schedule(): void {\n if (this.timer || !this.cfg.enabled) return\n this.timer = setTimeout(() => {\n this.timer = null\n this.flush()\n }, this.cfg.flushIntervalMs)\n }\n\n private clearTimer(): void {\n if (this.timer) {\n clearTimeout(this.timer)\n this.timer = null\n }\n }\n}\n\n/** createAnalytics builds a client instance. Most apps use one shared instance. */\nexport function createAnalytics(config: AnalyticsConfig): Analytics {\n return new Analytics(config)\n}\n\n// Re-export the hydrate helpers so consumers can read persisted cohort/attribution\n// (e.g. to send refCode to the referrals API) without reaching into storage.\nexport { getCohort, getFirstTouch }\n","// React bindings for @hanzo/event — a provider, an accessor hook, a route-change\n// pageview hook, and an error boundary. Framework-neutral: it takes the current\n// path as an argument (the Next app wires usePathname(); a Vite/router app passes\n// its own), so this file never imports next/*.\n//\n// 'use client'\n// import { AnalyticsProvider, useAnalytics, usePageview, ErrorBoundary } from '@hanzo/event/react'\n//\n// <AnalyticsProvider config={{ product: 'console' }}>…</AnalyticsProvider>\n// const a = useAnalytics(); usePageview(usePathname())\n\nimport {\n Component,\n createContext,\n createElement,\n useContext,\n useEffect,\n useMemo,\n useRef,\n type ErrorInfo,\n type ReactNode,\n} from 'react'\nimport { Analytics, createAnalytics } from './core'\nimport type { AnalyticsConfig } from './types'\n\nconst Ctx = createContext<Analytics | null>(null)\n\nexport interface AnalyticsProviderProps {\n /** Provide a pre-built client, or a config to build one. */\n client?: Analytics\n config?: AnalyticsConfig\n /** Fire a pageview for the initial load (default true). */\n autoPageview?: boolean\n children: ReactNode\n}\n\nexport function AnalyticsProvider(props: AnalyticsProviderProps) {\n const { client, config, autoPageview = true, children } = props\n const instance = useMemo<Analytics>(() => {\n if (client) return client\n if (config) return createAnalytics(config)\n throw new Error('AnalyticsProvider requires `client` or `config`')\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [client])\n\n useEffect(() => {\n instance.init()\n if (autoPageview) instance.pageview()\n }, [instance, autoPageview])\n\n return createElement(Ctx.Provider, { value: instance }, children)\n}\n\n/** useAnalytics returns the client. Outside a provider it returns a no-op-safe\n * null-guarded proxy so calls never throw during SSR/tests. */\nexport function useAnalytics(): Analytics {\n const a = useContext(Ctx)\n if (!a) return noop\n return a\n}\n\n/** usePageview fires a pageview on every route CHANGE (not the initial mount —\n * the provider's autoPageview covers that, so pages are counted exactly once). */\nexport function usePageview(path: string | null | undefined): void {\n const a = useContext(Ctx)\n const first = useRef(true)\n useEffect(() => {\n if (first.current) {\n first.current = false\n return\n }\n if (a && path) a.pageview(path)\n }, [a, path])\n}\n\n// A shared no-op client for use outside a provider — keeps call sites total.\nconst noop = new Analytics({ product: 'unknown', enabled: false })\n\nexport interface ErrorBoundaryProps {\n children: ReactNode\n /** Rendered when a child throws. A function receives the error + a reset. */\n fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)\n /** Explicit client; defaults to the one from AnalyticsProvider. */\n client?: Analytics\n}\n\ninterface ErrorBoundaryState {\n error: Error | null\n}\n\n/** ErrorBoundary reports React render errors to the client as error events —\n * React swallows these before window.onerror sees them, so a boundary is the\n * ONLY way to capture them. This is the React half of the @sentry replacement.\n * Pair it with AnalyticsProvider (it reads the client from context), or pass an\n * explicit `client`. */\nexport class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {\n static contextType = Ctx\n declare context: React.ContextType<typeof Ctx>\n state: ErrorBoundaryState = { error: null }\n\n static getDerivedStateFromError(error: Error): ErrorBoundaryState {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo): void {\n const client = this.props.client ?? this.context ?? undefined\n client?.captureError(error, {\n handled: false,\n properties: { componentStack: info.componentStack, react: true },\n })\n }\n\n private reset = () => this.setState({ error: null })\n\n render(): ReactNode {\n const { error } = this.state\n if (error === null) return this.props.children\n const { fallback } = this.props\n if (typeof fallback === 'function') return fallback(error, this.reset)\n return fallback ?? null\n }\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.2.0",
4
- "description": "Hanzo Event — the ONE telemetry client. Emits every kind of event — pageview/event/identify/group AND errors — to Hanzo Cloud (/v1/analytics + /v1/tracker) on one batched stream, lensed server-side into product analytics, web analytics, and error tracking. First-touch attribution, beacon-on-unload, auto error capture, a shared event + goal vocabulary. Subsumes @sentry.",
3
+ "version": "0.3.1",
4
+ "description": "Hanzo Event — the ONE telemetry client. Emits every kind of event — pageview/event/identify/group AND errors — to the ONE Hanzo Cloud front door (POST /v1/event) as one batched stream, lensed server-side into product analytics (insights), web analytics (analytics), and error tracking (sentry). First-touch attribution, beacon-on-unload, auto error capture, a shared event + goal vocabulary. Subsumes @sentry.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
7
7
  "access": "public",
@@ -10,7 +10,7 @@
10
10
  "author": "Hanzo AI <dev@hanzo.ai>",
11
11
  "license": "BSD-3-Clause",
12
12
  "type": "module",
13
- "main": "./dist/index.js",
13
+ "main": "./dist/index.cjs",
14
14
  "module": "./dist/index.mjs",
15
15
  "types": "./dist/index.d.ts",
16
16
  "sideEffects": false,
@@ -35,16 +35,33 @@
35
35
  "funnel",
36
36
  "hanzo"
37
37
  ],
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "dev": "tsup --watch",
41
+ "test": "vitest run",
42
+ "typecheck": "tsc --noEmit",
43
+ "clean": "rm -rf dist"
44
+ },
38
45
  "exports": {
39
46
  ".": {
40
- "types": "./dist/index.d.ts",
41
- "import": "./dist/index.mjs",
42
- "require": "./dist/index.js"
47
+ "import": {
48
+ "types": "./dist/index.d.ts",
49
+ "default": "./dist/index.mjs"
50
+ },
51
+ "require": {
52
+ "types": "./dist/index.d.cts",
53
+ "default": "./dist/index.cjs"
54
+ }
43
55
  },
44
56
  "./react": {
45
- "types": "./dist/react.d.ts",
46
- "import": "./dist/react.mjs",
47
- "require": "./dist/react.js"
57
+ "import": {
58
+ "types": "./dist/react.d.ts",
59
+ "default": "./dist/react.mjs"
60
+ },
61
+ "require": {
62
+ "types": "./dist/react.d.cts",
63
+ "default": "./dist/react.cjs"
64
+ }
48
65
  }
49
66
  },
50
67
  "peerDependencies": {
@@ -62,12 +79,5 @@
62
79
  "tsup": "^8.5.1",
63
80
  "typescript": "^5.9.3",
64
81
  "vitest": "^4.1.0"
65
- },
66
- "scripts": {
67
- "build": "tsup",
68
- "dev": "tsup --watch",
69
- "test": "vitest run",
70
- "typecheck": "tsc --noEmit",
71
- "clean": "rm -rf dist"
72
82
  }
73
- }
83
+ }
package/src/core.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest'
2
- import { Analytics } from './core'
2
+ import { Analytics, VERSION } from './core'
3
3
  import { EVENTS, PAGEVIEW } from './events'
4
4
  import type { Transport, WireEvent } from './types'
5
5
 
@@ -7,14 +7,25 @@ interface Sent {
7
7
  url: string
8
8
  beacon: boolean
9
9
  token?: string
10
+ ingestKey?: string
11
+ raw: string
10
12
  batch: WireEvent[]
11
13
  }
12
14
 
15
+ // FakeTransport records the EXACT bytes the client would put on the wire, so the
16
+ // tests assert the real POST /v1/event body shape ({ batch: [...] }), not a mock.
13
17
  class FakeTransport implements Transport {
14
18
  sent: Sent[] = []
15
- send(url: string, body: string, opts: { beacon: boolean; token?: string }) {
19
+ send(url: string, body: string, opts: { beacon: boolean; token?: string; ingestKey?: string }) {
16
20
  const parsed = JSON.parse(body) as { batch: WireEvent[] }
17
- this.sent.push({ url, beacon: opts.beacon, token: opts.token, batch: parsed.batch })
21
+ this.sent.push({
22
+ url,
23
+ beacon: opts.beacon,
24
+ token: opts.token,
25
+ ingestKey: opts.ingestKey,
26
+ raw: body,
27
+ batch: parsed.batch,
28
+ })
18
29
  }
19
30
  get all(): WireEvent[] {
20
31
  return this.sent.flatMap((s) => s.batch)
@@ -22,9 +33,11 @@ class FakeTransport implements Transport {
22
33
  }
23
34
 
24
35
  let tx: FakeTransport
36
+ // Default to same-origin (host:'') so path assertions read the bare /v1/event door;
37
+ // tests that care about the edge host pass it explicitly.
25
38
  function mk(overrides = {}) {
26
39
  tx = new FakeTransport()
27
- return new Analytics({ product: 'console', transport: tx, flushIntervalMs: 999999, ...overrides })
40
+ return new Analytics({ product: 'console', host: '', transport: tx, flushIntervalMs: 999999, ...overrides })
28
41
  }
29
42
 
30
43
  describe('Analytics capture', () => {
@@ -32,23 +45,57 @@ describe('Analytics capture', () => {
32
45
  tx = new FakeTransport()
33
46
  })
34
47
 
35
- it('flushes an event as a {batch:[...]} with product + name, no tenant/org field', () => {
48
+ it('flushes an event as { batch:[] } to /v1/event, no tenant/org field', () => {
36
49
  const a = mk()
37
50
  a.capture(EVENTS.SIGNUP_COMPLETED, { plan: 'pro' })
38
51
  a.flush()
39
52
  expect(tx.sent).toHaveLength(1)
40
- expect(tx.sent[0].url).toBe('/v1/analytics')
53
+ expect(tx.sent[0].url).toBe('/v1/event')
54
+ // The body is the canonical { batch: [...] } envelope, exactly one door.
55
+ const parsed = JSON.parse(tx.sent[0].raw)
56
+ expect(Array.isArray(parsed.batch)).toBe(true)
41
57
  const e = tx.all[0]
42
58
  expect(e.type).toBe('event')
43
59
  expect(e.event).toBe('signup_completed')
44
60
  expect(e.product).toBe('console')
45
61
  expect(e.properties).toEqual({ plan: 'pro' })
46
- // The client must never send a tenant/org — the server stamps it.
62
+ // The client must NEVER send a tenant/org — the server stamps it.
47
63
  expect((e as Record<string, unknown>).tenant).toBeUndefined()
48
64
  expect((e as Record<string, unknown>).org).toBeUndefined()
49
65
  expect((e as Record<string, unknown>).tenantId).toBeUndefined()
50
66
  })
51
67
 
68
+ it('the wire is the canonical /v1/event Event: only known cloud fields, no tenant', () => {
69
+ const a = mk({ host: 'https://api.hanzo.ai' })
70
+ a.identify('user-9')
71
+ a.capture(EVENTS.ORDER_COMPLETED, { kind: 'plan' }, { productId: 'plan_pro', revenue: 49, quantity: 1, currency: 'usd' })
72
+ a.flush()
73
+ // ONE POST, ONE door, ONE batched envelope.
74
+ expect(tx.sent).toHaveLength(1)
75
+ expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/event')
76
+ const parsed = JSON.parse(tx.sent[0].raw) as { batch: WireEvent[] }
77
+ expect(Object.keys(parsed)).toEqual(['batch'])
78
+ // Every field the client emits is a known cloud CaptureEvent field — no tenant.
79
+ const allowed = new Set([
80
+ 'messageId', 'type', 'event', 'timestamp', 'distinctId', 'anonymousId',
81
+ 'personId', 'sessionId', 'product', 'url', 'path', 'referrer', 'utm',
82
+ 'refCode', 'channel', 'groupId', 'signupWeek', 'productId', 'quantity',
83
+ 'revenue', 'currency', 'error', 'properties', 'library', 'libraryVersion',
84
+ ])
85
+ for (const ev of parsed.batch) {
86
+ for (const k of Object.keys(ev)) expect(allowed.has(k)).toBe(true)
87
+ expect((ev as Record<string, unknown>).tenant).toBeUndefined()
88
+ expect((ev as Record<string, unknown>).tenantId).toBeUndefined()
89
+ expect(ev.library).toBe('@hanzo/event')
90
+ expect(ev.libraryVersion).toBe(VERSION)
91
+ }
92
+ const order = parsed.batch.find((e) => e.event === EVENTS.ORDER_COMPLETED)!
93
+ expect(order.productId).toBe('plan_pro')
94
+ expect(order.revenue).toBe(49)
95
+ expect(order.quantity).toBe(1)
96
+ expect(order.currency).toBe('usd')
97
+ })
98
+
52
99
  it('pageview emits the reserved $pageview name', () => {
53
100
  const a = mk()
54
101
  a.pageview('/pricing')
@@ -107,16 +154,29 @@ describe('Analytics capture', () => {
107
154
  a.capture('x')
108
155
  a.flush(true) // beacon requested…
109
156
  expect(tx.sent[0].token).toBe('jwt-abc')
110
- expect(tx.sent[0].beacon).toBe(false) // …but a token forces keepalive fetch
111
- expect(tx.sent[0].url).toBe('/v1/analytics')
157
+ expect(tx.sent[0].beacon).toBe(false) // …but a JWT forces keepalive fetch
158
+ expect(tx.sent[0].url).toBe('/v1/event')
159
+ })
160
+
161
+ it('cookie apps beacon to /v1/event on unload flush', () => {
162
+ const a = mk() // no token, no key
163
+ a.capture('x')
164
+ a.flush(true)
165
+ expect(tx.sent[0].beacon).toBe(true)
166
+ expect(tx.sent[0].token).toBeUndefined()
167
+ expect(tx.sent[0].url).toBe('/v1/event')
112
168
  })
113
169
 
114
- it('cookie apps beacon to the tracker route on unload flush', () => {
115
- const a = mk() // no token
170
+ it('publishable-key apps ride ingestKey and still beacon on unload', () => {
171
+ const a = mk({ ingestKey: 'pk_live_123' })
116
172
  a.capture('x')
117
173
  a.flush(true)
174
+ // The key is offered to the transport (rides ?ingest_key on the beacon), and
175
+ // a publishable key does NOT block the unload beacon.
176
+ expect(tx.sent[0].ingestKey).toBe('pk_live_123')
177
+ expect(tx.sent[0].token).toBeUndefined()
118
178
  expect(tx.sent[0].beacon).toBe(true)
119
- expect(tx.sent[0].url).toBe('/v1/tracker')
179
+ expect(tx.sent[0].url).toBe('/v1/event')
120
180
  })
121
181
 
122
182
  it('setCohort rides subsequent events', () => {
@@ -134,30 +194,35 @@ describe('Analytics capture', () => {
134
194
  const a = mk({ host: 'https://api.hanzo.ai' })
135
195
  a.capture('x')
136
196
  a.flush()
137
- expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/analytics')
197
+ expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/event')
138
198
  })
139
199
 
140
- it('stamps every event with the @hanzo/event library id', () => {
200
+ it('stamps every event with the @hanzo/event library id + version', () => {
141
201
  const a = mk()
142
202
  a.capture('x')
143
203
  a.flush()
144
204
  expect(tx.all[0].library).toBe('@hanzo/event')
205
+ expect(tx.all[0].libraryVersion).toBe(VERSION)
145
206
  })
146
207
  })
147
208
 
148
209
  describe('Event error capture', () => {
149
- it('captureError emits a type:error event with the exception, and flushes at once', () => {
210
+ it('captureError emits a type:error event carrying a TOP-LEVEL exception, flushed at once', () => {
150
211
  const a = mk()
151
212
  a.captureError(new TypeError('boom'))
152
213
  // captureError flushes promptly — no explicit flush() needed.
153
214
  expect(tx.sent).toHaveLength(1)
154
215
  const e = tx.all[0]
216
+ // type:'error' is the field Cloud folds to event_type='error' → sentry lens.
155
217
  expect(e.type).toBe('error')
156
218
  expect(e.event).toBe('boom')
219
+ // The exception rides the TOP-LEVEL `error` field (what cloud foldException
220
+ // reads), NOT properties — a properties-only exception would not be lensed.
157
221
  expect(e.error?.type).toBe('TypeError')
158
222
  expect(e.error?.message).toBe('boom')
159
223
  expect(e.error?.stack).toBeTruthy()
160
224
  expect(e.error?.handled).toBe(true) // a caught, manually-reported error
225
+ expect((e.properties ?? {})).not.toHaveProperty('$exception')
161
226
  })
162
227
 
163
228
  it('normalizes a thrown string into an exception', () => {
@@ -184,11 +249,12 @@ describe('Event error capture', () => {
184
249
  expect(e.error?.message).toBe('via alias')
185
250
  })
186
251
 
187
- it('an error is still an event on the ONE stream — same route + product', () => {
252
+ it('an error is still an event on the ONE stream — same /v1/event door + product', () => {
188
253
  const a = mk({ host: 'https://api.hanzo.ai' })
189
254
  a.captureError(new Error('x'))
190
- // Same batched route as any other event — one pipe, not a second SDK.
191
- expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/analytics')
255
+ // Same batched door as any other event — one pipe, not a second SDK.
256
+ expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/event')
257
+ expect(tx.sent[0].raw.startsWith('{"batch":')).toBe(true)
192
258
  expect(tx.all[0].product).toBe('console')
193
259
  // never the tenant — the server stamps it, errors included.
194
260
  expect((tx.all[0] as Record<string, unknown>).tenant).toBeUndefined()