@hanzo/event 0.2.0

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.
@@ -0,0 +1,82 @@
1
+ // Pure attribution helpers: parse first-touch UTM/referrer/refCode from a URL,
2
+ // derive the acquisition channel, and compute the ISO week for the signup cohort.
3
+ // No I/O, no globals — trivially testable.
4
+
5
+ import type { Attribution } from './types'
6
+
7
+ const SOCIAL_HOSTS = [
8
+ 'facebook.', 'instagram.', 'twitter.', 'x.com', 't.co', 'linkedin.',
9
+ 'reddit.', 'youtube.', 'tiktok.', 'pinterest.', 'news.ycombinator.com',
10
+ ]
11
+ const SEARCH_HOSTS = ['google.', 'bing.', 'duckduckgo.', 'yahoo.', 'baidu.', 'ecosia.']
12
+
13
+ /** parseAttribution reads UTM params + ref/refCode from a query string and pairs
14
+ * them with the referrer. `search` is a location.search value ("?utm_source=…"). */
15
+ export function parseAttribution(search: string, referrer: string): Attribution {
16
+ const q = new URLSearchParams(search || '')
17
+ const get = (k: string) => {
18
+ const v = q.get(k)
19
+ return v ? v.trim() : undefined
20
+ }
21
+ const a: Attribution = {
22
+ utm: {
23
+ source: get('utm_source'),
24
+ medium: get('utm_medium'),
25
+ campaign: get('utm_campaign'),
26
+ term: get('utm_term'),
27
+ content: get('utm_content'),
28
+ },
29
+ referrer: referrer ? referrer.trim() : undefined,
30
+ refCode: get('ref') || get('refCode') || get('ref_code') || undefined,
31
+ }
32
+ a.channel = deriveChannel(a)
33
+ return a
34
+ }
35
+
36
+ /** deriveChannel classifies the visit: paid | referral | social | organic | direct. */
37
+ export function deriveChannel(a: Attribution): string {
38
+ const medium = (a.utm.medium || '').toLowerCase()
39
+ if (/(cpc|ppc|paid|paidsearch|display|cpm)/.test(medium)) return 'paid'
40
+ if (a.utm.source || a.utm.campaign) return 'campaign'
41
+ if (a.refCode) return 'referral'
42
+ const host = hostOf(a.referrer)
43
+ if (!host) return 'direct'
44
+ if (SOCIAL_HOSTS.some((h) => host.includes(h))) return 'social'
45
+ if (SEARCH_HOSTS.some((h) => host.includes(h))) return 'organic'
46
+ return 'referral'
47
+ }
48
+
49
+ /** hostOf extracts a bare lowercase host from a URL; "" when unparseable. */
50
+ export function hostOf(raw?: string): string {
51
+ if (!raw) return ''
52
+ let s = raw.trim()
53
+ const scheme = s.indexOf('://')
54
+ if (scheme >= 0) s = s.slice(scheme + 3)
55
+ const cut = s.search(/[/?#]/)
56
+ if (cut >= 0) s = s.slice(0, cut)
57
+ const at = s.indexOf('@')
58
+ if (at >= 0) s = s.slice(at + 1)
59
+ const colon = s.indexOf(':')
60
+ if (colon >= 0) s = s.slice(0, colon)
61
+ return s.toLowerCase().trim()
62
+ }
63
+
64
+ /** hasAttribution reports whether anything was captured (so we don't persist an
65
+ * empty first-touch that would shadow a later real one). */
66
+ export function hasAttribution(a: Attribution): boolean {
67
+ return Boolean(
68
+ a.utm.source || a.utm.medium || a.utm.campaign || a.utm.term ||
69
+ a.utm.content || a.refCode || (a.referrer && hostOf(a.referrer)),
70
+ )
71
+ }
72
+
73
+ /** isoWeek returns the ISO-8601 week label, e.g. "2026-W28". */
74
+ export function isoWeek(d: Date): string {
75
+ // Copy to UTC midnight; ISO week: Thursday-anchored.
76
+ const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
77
+ const day = date.getUTCDay() || 7
78
+ date.setUTCDate(date.getUTCDate() + 4 - day)
79
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
80
+ const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
81
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, '0')}`
82
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { Analytics } from './core'
3
+ import { EVENTS, PAGEVIEW } from './events'
4
+ import type { Transport, WireEvent } from './types'
5
+
6
+ interface Sent {
7
+ url: string
8
+ beacon: boolean
9
+ token?: string
10
+ batch: WireEvent[]
11
+ }
12
+
13
+ class FakeTransport implements Transport {
14
+ sent: Sent[] = []
15
+ send(url: string, body: string, opts: { beacon: boolean; token?: string }) {
16
+ const parsed = JSON.parse(body) as { batch: WireEvent[] }
17
+ this.sent.push({ url, beacon: opts.beacon, token: opts.token, batch: parsed.batch })
18
+ }
19
+ get all(): WireEvent[] {
20
+ return this.sent.flatMap((s) => s.batch)
21
+ }
22
+ }
23
+
24
+ let tx: FakeTransport
25
+ function mk(overrides = {}) {
26
+ tx = new FakeTransport()
27
+ return new Analytics({ product: 'console', transport: tx, flushIntervalMs: 999999, ...overrides })
28
+ }
29
+
30
+ describe('Analytics capture', () => {
31
+ beforeEach(() => {
32
+ tx = new FakeTransport()
33
+ })
34
+
35
+ it('flushes an event as a {batch:[...]} with product + name, no tenant/org field', () => {
36
+ const a = mk()
37
+ a.capture(EVENTS.SIGNUP_COMPLETED, { plan: 'pro' })
38
+ a.flush()
39
+ expect(tx.sent).toHaveLength(1)
40
+ expect(tx.sent[0].url).toBe('/v1/analytics')
41
+ const e = tx.all[0]
42
+ expect(e.type).toBe('event')
43
+ expect(e.event).toBe('signup_completed')
44
+ expect(e.product).toBe('console')
45
+ expect(e.properties).toEqual({ plan: 'pro' })
46
+ // The client must never send a tenant/org — the server stamps it.
47
+ expect((e as Record<string, unknown>).tenant).toBeUndefined()
48
+ expect((e as Record<string, unknown>).org).toBeUndefined()
49
+ expect((e as Record<string, unknown>).tenantId).toBeUndefined()
50
+ })
51
+
52
+ it('pageview emits the reserved $pageview name', () => {
53
+ const a = mk()
54
+ a.pageview('/pricing')
55
+ a.flush()
56
+ const e = tx.all[0]
57
+ expect(e.type).toBe('pageview')
58
+ expect(e.event).toBe(PAGEVIEW)
59
+ expect(e.path).toBe('/pricing')
60
+ })
61
+
62
+ it('auto-flushes when the batch size is reached', () => {
63
+ const a = mk({ batchSize: 3 })
64
+ a.capture('a')
65
+ a.capture('b')
66
+ expect(tx.sent).toHaveLength(0) // under threshold, buffered
67
+ a.capture('c')
68
+ expect(tx.sent).toHaveLength(1) // threshold hit → flushed
69
+ expect(tx.all).toHaveLength(3)
70
+ })
71
+
72
+ it('identify binds personId to subsequent distinctId', () => {
73
+ const a = mk()
74
+ a.capture('anon_event')
75
+ a.identify('user-42')
76
+ a.capture('known_event')
77
+ a.flush()
78
+ const anon = tx.all.find((e) => e.event === 'anon_event')!
79
+ const known = tx.all.find((e) => e.event === 'known_event')!
80
+ expect(known.personId).toBe('user-42')
81
+ expect(known.distinctId).toBe('user-42')
82
+ // the pre-identify event has no personId
83
+ expect(anon.personId).toBeUndefined()
84
+ })
85
+
86
+ it('carries commerce fields on order events', () => {
87
+ const a = mk()
88
+ a.capture(EVENTS.ORDER_COMPLETED, { kind: 'plan' }, { productId: 'plan_pro', revenue: 49, quantity: 1, currency: 'usd' })
89
+ a.flush()
90
+ const e = tx.all[0]
91
+ expect(e.productId).toBe('plan_pro')
92
+ expect(e.revenue).toBe(49)
93
+ expect(e.quantity).toBe(1)
94
+ expect(e.currency).toBe('usd')
95
+ })
96
+
97
+ it('a disabled client emits nothing', () => {
98
+ const a = mk({ enabled: false })
99
+ a.capture('x')
100
+ a.pageview()
101
+ a.flush()
102
+ expect(tx.sent).toHaveLength(0)
103
+ })
104
+
105
+ it('token apps send Authorization and never beacon (headerless) on unload flush', () => {
106
+ const a = mk({ getToken: () => 'jwt-abc' })
107
+ a.capture('x')
108
+ a.flush(true) // beacon requested…
109
+ 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')
112
+ })
113
+
114
+ it('cookie apps beacon to the tracker route on unload flush', () => {
115
+ const a = mk() // no token
116
+ a.capture('x')
117
+ a.flush(true)
118
+ expect(tx.sent[0].beacon).toBe(true)
119
+ expect(tx.sent[0].url).toBe('/v1/tracker')
120
+ })
121
+
122
+ it('setCohort rides subsequent events', () => {
123
+ const a = mk()
124
+ a.setCohort({ signupWeek: '2026-W29', channel: 'paid', refCode: 'REF9' })
125
+ a.capture('x')
126
+ a.flush()
127
+ const e = tx.all[0]
128
+ expect(e.signupWeek).toBe('2026-W29')
129
+ expect(e.channel).toBe('paid')
130
+ expect(e.refCode).toBe('REF9')
131
+ })
132
+
133
+ it('prefixes the configured host onto the path', () => {
134
+ const a = mk({ host: 'https://api.hanzo.ai' })
135
+ a.capture('x')
136
+ a.flush()
137
+ expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/analytics')
138
+ })
139
+
140
+ it('stamps every event with the @hanzo/event library id', () => {
141
+ const a = mk()
142
+ a.capture('x')
143
+ a.flush()
144
+ expect(tx.all[0].library).toBe('@hanzo/event')
145
+ })
146
+ })
147
+
148
+ describe('Event error capture', () => {
149
+ it('captureError emits a type:error event with the exception, and flushes at once', () => {
150
+ const a = mk()
151
+ a.captureError(new TypeError('boom'))
152
+ // captureError flushes promptly — no explicit flush() needed.
153
+ expect(tx.sent).toHaveLength(1)
154
+ const e = tx.all[0]
155
+ expect(e.type).toBe('error')
156
+ expect(e.event).toBe('boom')
157
+ expect(e.error?.type).toBe('TypeError')
158
+ expect(e.error?.message).toBe('boom')
159
+ expect(e.error?.stack).toBeTruthy()
160
+ expect(e.error?.handled).toBe(true) // a caught, manually-reported error
161
+ })
162
+
163
+ it('normalizes a thrown string into an exception', () => {
164
+ const a = mk()
165
+ a.captureError('plain failure')
166
+ const e = tx.all[0]
167
+ expect(e.type).toBe('error')
168
+ expect(e.error?.message).toBe('plain failure')
169
+ })
170
+
171
+ it('marks handled=false for unhandled/global errors and carries properties', () => {
172
+ const a = mk()
173
+ a.captureError(new Error('unhandled'), { handled: false, properties: { source: 'onerror' } })
174
+ const e = tx.all[0]
175
+ expect(e.error?.handled).toBe(false)
176
+ expect(e.properties).toEqual({ source: 'onerror' })
177
+ })
178
+
179
+ it('captureException is an alias of captureError', () => {
180
+ const a = mk()
181
+ a.captureException(new Error('via alias'))
182
+ const e = tx.all[0]
183
+ expect(e.type).toBe('error')
184
+ expect(e.error?.message).toBe('via alias')
185
+ })
186
+
187
+ it('an error is still an event on the ONE stream — same route + product', () => {
188
+ const a = mk({ host: 'https://api.hanzo.ai' })
189
+ 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')
192
+ expect(tx.all[0].product).toBe('console')
193
+ // never the tenant — the server stamps it, errors included.
194
+ expect((tx.all[0] as Record<string, unknown>).tenant).toBeUndefined()
195
+ })
196
+ })
package/src/core.ts ADDED
@@ -0,0 +1,274 @@
1
+ // The framework-agnostic event client. Buffers events and flushes them as one
2
+ // batch to Hanzo Cloud — /v1/analytics normally, /v1/tracker via sendBeacon on
3
+ // page unload. It NEVER sends the org/tenant: the server stamps that from the
4
+ // validated session. The client only supplies its own visitor identity. Errors
5
+ // are just events (type:'error') on the same stream — one client, one pipe.
6
+
7
+ import {
8
+ parseAttribution,
9
+ hasAttribution,
10
+ deriveChannel,
11
+ } from './attribution'
12
+ import { PAGEVIEW } from './events'
13
+ import {
14
+ anonId,
15
+ sessionId,
16
+ getFirstTouch,
17
+ setFirstTouchOnce,
18
+ getCohort,
19
+ mergeCohort,
20
+ } from './storage'
21
+ import type {
22
+ AnalyticsConfig,
23
+ Attribution,
24
+ Cohort,
25
+ EventKind,
26
+ Exception,
27
+ Transport,
28
+ WireEvent,
29
+ } from './types'
30
+
31
+ export const VERSION = '0.2.0'
32
+
33
+ const ANALYTICS_PATH = '/v1/analytics'
34
+ const TRACKER_PATH = '/v1/tracker' // beacon-on-unload alias
35
+
36
+ function uid(): string {
37
+ const c = typeof crypto !== 'undefined' ? crypto : undefined
38
+ if (c && 'randomUUID' in c) return c.randomUUID()
39
+ return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
40
+ }
41
+
42
+ /** Normalize anything thrown (Error | string | unknown) into an Exception. */
43
+ function normalizeError(err: unknown): Exception {
44
+ if (err instanceof Error) {
45
+ return { type: err.name, message: err.message, stack: err.stack }
46
+ }
47
+ if (typeof err === 'string') return { message: err }
48
+ try {
49
+ return { message: JSON.stringify(err) }
50
+ } catch {
51
+ return { message: String(err) }
52
+ }
53
+ }
54
+
55
+ const isBrowser = () => typeof window !== 'undefined'
56
+
57
+ /** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
58
+ * navigator.sendBeacon for headerless page-unload beacons. */
59
+ class DefaultTransport implements Transport {
60
+ send(url: string, body: string, opts: { beacon: boolean; token?: string }): void {
61
+ if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {
62
+ try {
63
+ navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
64
+ return
65
+ } catch {
66
+ /* fall through to fetch */
67
+ }
68
+ }
69
+ if (typeof fetch !== 'function') return
70
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
71
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`
72
+ void fetch(url, {
73
+ method: 'POST',
74
+ headers,
75
+ body,
76
+ keepalive: true,
77
+ credentials: 'include',
78
+ }).catch(() => {
79
+ /* analytics loss is acceptable; never throw into the app */
80
+ })
81
+ }
82
+ }
83
+
84
+ export class Analytics {
85
+ private cfg: Required<
86
+ Pick<AnalyticsConfig, 'product' | 'batchSize' | 'flushIntervalMs' | 'enabled' | 'captureErrors'>
87
+ > &
88
+ AnalyticsConfig
89
+ private transport: Transport
90
+ private queue: WireEvent[] = []
91
+ private timer: ReturnType<typeof setTimeout> | null = null
92
+ private personId?: string
93
+ private attribution: Attribution = { utm: {} }
94
+ private cohort: Cohort = {}
95
+ private started = false
96
+
97
+ constructor(config: AnalyticsConfig) {
98
+ this.cfg = {
99
+ host: '',
100
+ batchSize: 20,
101
+ flushIntervalMs: 5000,
102
+ enabled: true,
103
+ captureErrors: true,
104
+ ...config,
105
+ }
106
+ this.transport = config.transport ?? new DefaultTransport()
107
+ }
108
+
109
+ /** init is idempotent and browser-only for its side effects: capture first-touch
110
+ * attribution, hydrate cohort, and register the unload flush. Safe to call from
111
+ * a React effect on every render. */
112
+ init(): void {
113
+ if (this.started || !this.cfg.enabled) return
114
+ this.started = true
115
+ if (!isBrowser()) return
116
+
117
+ const parsed = parseAttribution(window.location.search, document.referrer)
118
+ this.attribution = hasAttribution(parsed)
119
+ ? setFirstTouchOnce(parsed)
120
+ : getFirstTouch() ?? parsed
121
+ this.cohort = mergeCohort({
122
+ channel: this.attribution.channel ?? deriveChannel(this.attribution),
123
+ refCode: this.attribution.refCode,
124
+ })
125
+
126
+ const flushHidden = () => {
127
+ if (document.visibilityState === 'hidden') this.flush(true)
128
+ }
129
+ window.addEventListener('visibilitychange', flushHidden)
130
+ window.addEventListener('pagehide', () => this.flush(true))
131
+
132
+ // Auto error capture — the drop-in @sentry replacement. Unhandled errors and
133
+ // rejected promises become type:'error' events on the same stream.
134
+ if (this.cfg.captureErrors) {
135
+ window.addEventListener('error', (e: ErrorEvent) => {
136
+ this.captureError(e.error ?? e.message, { handled: false })
137
+ })
138
+ window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
139
+ this.captureError(e.reason, { handled: false })
140
+ })
141
+ }
142
+ }
143
+
144
+ /** identify binds the current visitor to a stable person id (post-login). */
145
+ identify(personId: string, traits?: Record<string, unknown>): void {
146
+ this.personId = personId
147
+ this.enqueue('identify', undefined, { properties: traits })
148
+ }
149
+
150
+ /** group associates the visitor with an org/team (analytics grouping, not the
151
+ * server tenant — the server still derives tenant from the session). */
152
+ group(groupId: string, traits?: Record<string, unknown>): void {
153
+ this.enqueue('group', undefined, { groupId, properties: traits })
154
+ }
155
+
156
+ /** pageview records a $pageview for the current (or given) location. */
157
+ pageview(path?: string, properties?: Record<string, unknown>): void {
158
+ const url = isBrowser() ? window.location.href : undefined
159
+ const p = path ?? (isBrowser() ? window.location.pathname : undefined)
160
+ this.enqueue('pageview', PAGEVIEW, { url, path: p, properties })
161
+ }
162
+
163
+ /** capture records a named product event with optional properties. Commerce
164
+ * fields (productId/quantity/revenue/currency) may be passed for order events. */
165
+ capture(
166
+ event: string,
167
+ properties?: Record<string, unknown>,
168
+ commerce?: Pick<WireEvent, 'productId' | 'quantity' | 'revenue' | 'currency'>,
169
+ ): void {
170
+ this.enqueue('event', event, { properties, ...commerce })
171
+ }
172
+
173
+ /** track is an alias of capture (Segment familiarity). */
174
+ track = this.capture.bind(this)
175
+
176
+ /** captureError records an exception as a first-class error event — the ONE
177
+ * error path (subsumes @sentry). A caught error, an unhandled rejection, or a
178
+ * manual report all become a type:'error' event on the same stream, lensed to
179
+ * the error-tracking view server-side. Never throws back into the app; errors
180
+ * are higher-signal than pageviews, so it flushes promptly (a crash may unload
181
+ * the page moments later). */
182
+ captureError(
183
+ err: unknown,
184
+ context?: { handled?: boolean; properties?: Record<string, unknown> },
185
+ ): void {
186
+ const ex = normalizeError(err)
187
+ ex.handled = context?.handled ?? true
188
+ this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
189
+ this.flush()
190
+ }
191
+
192
+ /** captureException — @sentry-familiar alias of captureError. */
193
+ captureException = this.captureError.bind(this)
194
+
195
+ /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
196
+ * every subsequent event. */
197
+ setCohort(patch: Cohort): void {
198
+ this.cohort = mergeCohort(patch)
199
+ }
200
+
201
+ /** flush drains the buffer to the server as one batch. beacon=true uses the
202
+ * unload-safe path. */
203
+ flush(beacon = false): void {
204
+ if (!this.cfg.enabled || this.queue.length === 0) return
205
+ const batch = this.queue
206
+ this.queue = []
207
+ this.clearTimer()
208
+ const token = this.cfg.getToken?.() ?? undefined
209
+ // sendBeacon cannot carry an Authorization header, so token apps always use
210
+ // keepalive fetch; cookie apps may beacon to the tracker route on unload.
211
+ const useBeacon = beacon && !token
212
+ const path = useBeacon ? TRACKER_PATH : ANALYTICS_PATH
213
+ const body = JSON.stringify({ batch })
214
+ if (this.cfg.debug) console.debug('[analytics] flush', batch.length, path)
215
+ this.transport.send(this.cfg.host + path, body, { beacon: useBeacon, token: token ?? undefined })
216
+ }
217
+
218
+ // ── internals ────────────────────────────────────────────────────────────
219
+
220
+ private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {
221
+ if (!this.cfg.enabled) return
222
+ if (!this.started) this.init()
223
+ this.queue.push(this.build(kind, event, extra))
224
+ if (this.queue.length >= this.cfg.batchSize) this.flush()
225
+ else this.schedule()
226
+ }
227
+
228
+ private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
229
+ const anon = anonId()
230
+ return {
231
+ messageId: uid(),
232
+ type: kind,
233
+ event,
234
+ timestamp: new Date().toISOString(),
235
+ distinctId: this.personId ?? anon,
236
+ anonymousId: anon,
237
+ personId: this.personId,
238
+ sessionId: sessionId(),
239
+ product: this.cfg.product,
240
+ referrer: this.attribution.referrer,
241
+ utm: this.attribution.utm,
242
+ refCode: this.cohort.refCode ?? this.attribution.refCode,
243
+ channel: this.cohort.channel ?? this.attribution.channel,
244
+ signupWeek: this.cohort.signupWeek,
245
+ library: '@hanzo/event',
246
+ libraryVersion: VERSION,
247
+ ...extra,
248
+ }
249
+ }
250
+
251
+ private schedule(): void {
252
+ if (this.timer || !this.cfg.enabled) return
253
+ this.timer = setTimeout(() => {
254
+ this.timer = null
255
+ this.flush()
256
+ }, this.cfg.flushIntervalMs)
257
+ }
258
+
259
+ private clearTimer(): void {
260
+ if (this.timer) {
261
+ clearTimeout(this.timer)
262
+ this.timer = null
263
+ }
264
+ }
265
+ }
266
+
267
+ /** createAnalytics builds a client instance. Most apps use one shared instance. */
268
+ export function createAnalytics(config: AnalyticsConfig): Analytics {
269
+ return new Analytics(config)
270
+ }
271
+
272
+ // Re-export the hydrate helpers so consumers can read persisted cohort/attribution
273
+ // (e.g. to send refCode to the referrals API) without reaching into storage.
274
+ export { getCohort, getFirstTouch }
package/src/events.ts ADDED
@@ -0,0 +1,42 @@
1
+ // The ONE product-analytics vocabulary. Every Hanzo surface emits these exact
2
+ // names so funnels, goals, and cohorts line up across console/chat/app/site/admin.
3
+ // Pageviews use the reserved "$pageview" name (emitted by analytics.pageview()),
4
+ // matching the server read lens.
5
+
6
+ export const EVENTS = {
7
+ // Signup funnel: view -> submit -> verify -> completed -> first action.
8
+ SIGNUP_VIEWED: 'signup_viewed',
9
+ SIGNUP_SUBMITTED: 'signup_submitted',
10
+ SIGNUP_VERIFIED: 'signup_verified',
11
+ SIGNUP_COMPLETED: 'signup_completed',
12
+ FIRST_ACTION: 'first_action',
13
+
14
+ // Waitlist + referral.
15
+ WAITLIST_JOINED: 'waitlist_joined',
16
+ WAITLIST_SHARED: 'waitlist_shared',
17
+ REFERRAL_USED: 'referral_used',
18
+ REFERRAL_CLAIMED: 'referral_claimed',
19
+
20
+ // Upgrade-intent + purchase.
21
+ PRICING_VIEWED: 'pricing_viewed',
22
+ PLAN_CLICKED: 'plan_clicked',
23
+ CHECKOUT_STARTED: 'checkout_started',
24
+ ORDER_COMPLETED: 'order_completed',
25
+
26
+ // Feature usage — generic + the common key surfaces across products.
27
+ FEATURE_USED: 'feature_used',
28
+ API_KEY_CREATED: 'api_key_created',
29
+ APP_CREATED: 'app_created',
30
+ DEPLOY_STARTED: 'deploy_started',
31
+ PROJECT_CREATED: 'project_created',
32
+ AGENT_CREATED: 'agent_created',
33
+ CHAT_STARTED: 'chat_started',
34
+ CHAT_MESSAGE_SENT: 'chat_message_sent',
35
+ TASK_STARTED: 'task_started',
36
+ TASK_COMPLETED: 'task_completed',
37
+ } as const
38
+
39
+ export type EventName = (typeof EVENTS)[keyof typeof EVENTS]
40
+
41
+ /** The reserved event name a pageview is stored under (server + read lens). */
42
+ export const PAGEVIEW = '$pageview'
package/src/goals.ts ADDED
@@ -0,0 +1,55 @@
1
+ // Insights goals + cohorts, defined once as data so the console/insights UI and
2
+ // every product agree on what "a Signup", "a Sale", and "upgrade intent" mean.
3
+ // This is the machine-readable spec — the shared source of truth a sync step can
4
+ // push into Insights, and what the guide documents.
5
+
6
+ import { EVENTS } from './events'
7
+
8
+ export interface GoalDef {
9
+ /** Human label shown in Insights. */
10
+ label: string
11
+ /** The event whose occurrence counts as the goal conversion. */
12
+ event: string
13
+ /** Optional ordered funnel leading to the goal (for funnel insights). */
14
+ funnel?: string[]
15
+ /** Optional property equality filter that qualifies the conversion. */
16
+ filter?: { property: string; equals: string }
17
+ }
18
+
19
+ export const GOALS: Record<'signup' | 'sale' | 'upgradeIntent', GoalDef> = {
20
+ // Signup: the conversion is signup_completed; the funnel is the four steps.
21
+ signup: {
22
+ label: 'Signup',
23
+ event: EVENTS.SIGNUP_COMPLETED,
24
+ funnel: [
25
+ EVENTS.SIGNUP_VIEWED,
26
+ EVENTS.SIGNUP_SUBMITTED,
27
+ EVENTS.SIGNUP_VERIFIED,
28
+ EVENTS.FIRST_ACTION,
29
+ ],
30
+ },
31
+ // Sale: a completed order qualified as a plan purchase (kind=plan).
32
+ sale: {
33
+ label: 'Sale',
34
+ event: EVENTS.ORDER_COMPLETED,
35
+ filter: { property: 'kind', equals: 'plan' },
36
+ },
37
+ // Upgrade intent: a plan click; pricing_viewed is the top of its funnel.
38
+ upgradeIntent: {
39
+ label: 'Upgrade Intent',
40
+ event: EVENTS.PLAN_CLICKED,
41
+ funnel: [EVENTS.PRICING_VIEWED, EVENTS.PLAN_CLICKED, EVENTS.CHECKOUT_STARTED],
42
+ },
43
+ }
44
+
45
+ export interface CohortDef {
46
+ /** The hanzo.events column the cohort dimension maps to. */
47
+ field: string
48
+ label: string
49
+ }
50
+
51
+ export const COHORTS: Record<'signupWeek' | 'channel' | 'refCode', CohortDef> = {
52
+ signupWeek: { field: 'signup_week', label: 'Signup week' },
53
+ channel: { field: 'channel', label: 'Acquisition channel' },
54
+ refCode: { field: 'ref_code', label: 'Referral code' },
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ // @hanzo/event — framework-agnostic entry. The ONE telemetry client.
2
+ //
3
+ // import { createAnalytics, EVENTS } from '@hanzo/event'
4
+ // const a = createAnalytics({ product: 'console' }) // same-origin, cookie auth
5
+ // a.pageview(); a.capture(EVENTS.SIGNUP_COMPLETED)
6
+ // a.captureError(err) // errors are events too — one stream
7
+ //
8
+ // React apps use the './react' entry for the provider + hooks + error boundary.
9
+
10
+ export { Analytics, createAnalytics, VERSION, getCohort, getFirstTouch } from './core'
11
+ export { EVENTS, PAGEVIEW } from './events'
12
+ export type { EventName } from './events'
13
+ export { GOALS, COHORTS } from './goals'
14
+ export type { GoalDef, CohortDef } from './goals'
15
+ export {
16
+ parseAttribution,
17
+ deriveChannel,
18
+ hasAttribution,
19
+ hostOf,
20
+ isoWeek,
21
+ } from './attribution'
22
+ export type {
23
+ AnalyticsConfig,
24
+ Attribution,
25
+ Cohort,
26
+ EventKind,
27
+ Exception,
28
+ Transport,
29
+ WireEvent,
30
+ } from './types'