@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.
- package/LICENSE.md +21 -0
- package/README.md +67 -0
- package/dist/core-DDGwms7M.d.cts +157 -0
- package/dist/core-DDGwms7M.d.ts +157 -0
- package/dist/index.d.cts +72 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +433 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +418 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react.d.cts +46 -0
- package/dist/react.d.ts +46 -0
- package/dist/react.js +416 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +411 -0
- package/dist/react.mjs.map +1 -0
- package/package.json +73 -0
- package/src/attribution.test.ts +66 -0
- package/src/attribution.ts +82 -0
- package/src/core.test.ts +196 -0
- package/src/core.ts +274 -0
- package/src/events.ts +42 -0
- package/src/goals.ts +55 -0
- package/src/index.ts +30 -0
- package/src/react.tsx +122 -0
- package/src/storage.ts +112 -0
- package/src/types.ts +104 -0
package/src/react.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// React bindings for @hanzo/event — a provider, an accessor hook, a route-change
|
|
2
|
+
// pageview hook, and an error boundary. Framework-neutral: it takes the current
|
|
3
|
+
// path as an argument (the Next app wires usePathname(); a Vite/router app passes
|
|
4
|
+
// its own), so this file never imports next/*.
|
|
5
|
+
//
|
|
6
|
+
// 'use client'
|
|
7
|
+
// import { AnalyticsProvider, useAnalytics, usePageview, ErrorBoundary } from '@hanzo/event/react'
|
|
8
|
+
//
|
|
9
|
+
// <AnalyticsProvider config={{ product: 'console' }}>…</AnalyticsProvider>
|
|
10
|
+
// const a = useAnalytics(); usePageview(usePathname())
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Component,
|
|
14
|
+
createContext,
|
|
15
|
+
createElement,
|
|
16
|
+
useContext,
|
|
17
|
+
useEffect,
|
|
18
|
+
useMemo,
|
|
19
|
+
useRef,
|
|
20
|
+
type ErrorInfo,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
} from 'react'
|
|
23
|
+
import { Analytics, createAnalytics } from './core'
|
|
24
|
+
import type { AnalyticsConfig } from './types'
|
|
25
|
+
|
|
26
|
+
const Ctx = createContext<Analytics | null>(null)
|
|
27
|
+
|
|
28
|
+
export interface AnalyticsProviderProps {
|
|
29
|
+
/** Provide a pre-built client, or a config to build one. */
|
|
30
|
+
client?: Analytics
|
|
31
|
+
config?: AnalyticsConfig
|
|
32
|
+
/** Fire a pageview for the initial load (default true). */
|
|
33
|
+
autoPageview?: boolean
|
|
34
|
+
children: ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function AnalyticsProvider(props: AnalyticsProviderProps) {
|
|
38
|
+
const { client, config, autoPageview = true, children } = props
|
|
39
|
+
const instance = useMemo<Analytics>(() => {
|
|
40
|
+
if (client) return client
|
|
41
|
+
if (config) return createAnalytics(config)
|
|
42
|
+
throw new Error('AnalyticsProvider requires `client` or `config`')
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, [client])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
instance.init()
|
|
48
|
+
if (autoPageview) instance.pageview()
|
|
49
|
+
}, [instance, autoPageview])
|
|
50
|
+
|
|
51
|
+
return createElement(Ctx.Provider, { value: instance }, children)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** useAnalytics returns the client. Outside a provider it returns a no-op-safe
|
|
55
|
+
* null-guarded proxy so calls never throw during SSR/tests. */
|
|
56
|
+
export function useAnalytics(): Analytics {
|
|
57
|
+
const a = useContext(Ctx)
|
|
58
|
+
if (!a) return noop
|
|
59
|
+
return a
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** usePageview fires a pageview on every route CHANGE (not the initial mount —
|
|
63
|
+
* the provider's autoPageview covers that, so pages are counted exactly once). */
|
|
64
|
+
export function usePageview(path: string | null | undefined): void {
|
|
65
|
+
const a = useContext(Ctx)
|
|
66
|
+
const first = useRef(true)
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (first.current) {
|
|
69
|
+
first.current = false
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (a && path) a.pageview(path)
|
|
73
|
+
}, [a, path])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A shared no-op client for use outside a provider — keeps call sites total.
|
|
77
|
+
const noop = new Analytics({ product: 'unknown', enabled: false })
|
|
78
|
+
|
|
79
|
+
export interface ErrorBoundaryProps {
|
|
80
|
+
children: ReactNode
|
|
81
|
+
/** Rendered when a child throws. A function receives the error + a reset. */
|
|
82
|
+
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
|
|
83
|
+
/** Explicit client; defaults to the one from AnalyticsProvider. */
|
|
84
|
+
client?: Analytics
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ErrorBoundaryState {
|
|
88
|
+
error: Error | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** ErrorBoundary reports React render errors to the client as error events —
|
|
92
|
+
* React swallows these before window.onerror sees them, so a boundary is the
|
|
93
|
+
* ONLY way to capture them. This is the React half of the @sentry replacement.
|
|
94
|
+
* Pair it with AnalyticsProvider (it reads the client from context), or pass an
|
|
95
|
+
* explicit `client`. */
|
|
96
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
97
|
+
static contextType = Ctx
|
|
98
|
+
declare context: React.ContextType<typeof Ctx>
|
|
99
|
+
state: ErrorBoundaryState = { error: null }
|
|
100
|
+
|
|
101
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
102
|
+
return { error }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
106
|
+
const client = this.props.client ?? this.context ?? undefined
|
|
107
|
+
client?.captureError(error, {
|
|
108
|
+
handled: false,
|
|
109
|
+
properties: { componentStack: info.componentStack, react: true },
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private reset = () => this.setState({ error: null })
|
|
114
|
+
|
|
115
|
+
render(): ReactNode {
|
|
116
|
+
const { error } = this.state
|
|
117
|
+
if (error === null) return this.props.children
|
|
118
|
+
const { fallback } = this.props
|
|
119
|
+
if (typeof fallback === 'function') return fallback(error, this.reset)
|
|
120
|
+
return fallback ?? null
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SSR-safe browser storage for stable identifiers and first-touch state. Every
|
|
2
|
+
// accessor no-ops (returns undefined) when there is no window/localStorage, so the
|
|
3
|
+
// client imports cleanly in a Next.js server component.
|
|
4
|
+
|
|
5
|
+
import type { Attribution, Cohort } from './types'
|
|
6
|
+
|
|
7
|
+
const KEY = {
|
|
8
|
+
anon: 'hz_anon_id',
|
|
9
|
+
session: 'hz_session',
|
|
10
|
+
firstTouch: 'hz_first_touch',
|
|
11
|
+
cohort: 'hz_cohort',
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
/** 30-minute inactivity window defines a session (PostHog/GA convention). */
|
|
15
|
+
const SESSION_TTL_MS = 30 * 60 * 1000
|
|
16
|
+
|
|
17
|
+
function ls(): Storage | undefined {
|
|
18
|
+
try {
|
|
19
|
+
if (typeof window === 'undefined' || !window.localStorage) return undefined
|
|
20
|
+
return window.localStorage
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined // Safari private mode / blocked storage
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function uid(): string {
|
|
27
|
+
const c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
28
|
+
if (c && 'randomUUID' in c) return c.randomUUID()
|
|
29
|
+
return 'a-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stable anonymous id, minted once per browser and reused across sessions. */
|
|
33
|
+
export function anonId(): string | undefined {
|
|
34
|
+
const s = ls()
|
|
35
|
+
if (!s) return undefined
|
|
36
|
+
let v = s.getItem(KEY.anon)
|
|
37
|
+
if (!v) {
|
|
38
|
+
v = uid()
|
|
39
|
+
s.setItem(KEY.anon, v)
|
|
40
|
+
}
|
|
41
|
+
return v
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SessionState {
|
|
45
|
+
id: string
|
|
46
|
+
last: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Current session id, rotated after SESSION_TTL_MS of inactivity. */
|
|
50
|
+
export function sessionId(now = Date.now()): string | undefined {
|
|
51
|
+
const s = ls()
|
|
52
|
+
if (!s) return undefined
|
|
53
|
+
let state: SessionState | null = null
|
|
54
|
+
try {
|
|
55
|
+
state = JSON.parse(s.getItem(KEY.session) || 'null')
|
|
56
|
+
} catch {
|
|
57
|
+
state = null
|
|
58
|
+
}
|
|
59
|
+
if (!state || now - state.last > SESSION_TTL_MS) {
|
|
60
|
+
state = { id: uid(), last: now }
|
|
61
|
+
} else {
|
|
62
|
+
state.last = now
|
|
63
|
+
}
|
|
64
|
+
s.setItem(KEY.session, JSON.stringify(state))
|
|
65
|
+
return state.id
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read the persisted first-touch attribution. */
|
|
69
|
+
export function getFirstTouch(): Attribution | undefined {
|
|
70
|
+
const s = ls()
|
|
71
|
+
if (!s) return undefined
|
|
72
|
+
try {
|
|
73
|
+
const v = s.getItem(KEY.firstTouch)
|
|
74
|
+
return v ? (JSON.parse(v) as Attribution) : undefined
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Persist first-touch attribution ONCE — never overwrite an existing record. */
|
|
81
|
+
export function setFirstTouchOnce(a: Attribution): Attribution {
|
|
82
|
+
const s = ls()
|
|
83
|
+
const existing = getFirstTouch()
|
|
84
|
+
if (existing) return existing
|
|
85
|
+
if (s) s.setItem(KEY.firstTouch, JSON.stringify(a))
|
|
86
|
+
return a
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read persisted cohort dimensions. */
|
|
90
|
+
export function getCohort(): Cohort | undefined {
|
|
91
|
+
const s = ls()
|
|
92
|
+
if (!s) return undefined
|
|
93
|
+
try {
|
|
94
|
+
const v = s.getItem(KEY.cohort)
|
|
95
|
+
return v ? (JSON.parse(v) as Cohort) : undefined
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Merge + persist cohort dimensions (signupWeek set once). */
|
|
102
|
+
export function mergeCohort(patch: Cohort): Cohort {
|
|
103
|
+
const s = ls()
|
|
104
|
+
const cur = getCohort() || {}
|
|
105
|
+
const next: Cohort = {
|
|
106
|
+
signupWeek: cur.signupWeek || patch.signupWeek,
|
|
107
|
+
channel: patch.channel || cur.channel,
|
|
108
|
+
refCode: cur.refCode || patch.refCode,
|
|
109
|
+
}
|
|
110
|
+
if (s) s.setItem(KEY.cohort, JSON.stringify(next))
|
|
111
|
+
return next
|
|
112
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Public types for the Hanzo Event client.
|
|
2
|
+
|
|
3
|
+
/** The event kinds — the closed set the server understands. An error is just
|
|
4
|
+
* another event on the one stream (lensed to the error-tracking view). */
|
|
5
|
+
export type EventKind = 'pageview' | 'event' | 'identify' | 'group' | 'error'
|
|
6
|
+
|
|
7
|
+
/** A captured exception. Carried on a `type:'error'` event; the server lenses it
|
|
8
|
+
* into the error-tracking view (sentry.hanzo.ai). */
|
|
9
|
+
export interface Exception {
|
|
10
|
+
/** Constructor/class name, e.g. "TypeError". */
|
|
11
|
+
type?: string
|
|
12
|
+
/** The error message. */
|
|
13
|
+
message: string
|
|
14
|
+
/** Stack trace when available. */
|
|
15
|
+
stack?: string
|
|
16
|
+
/** false = an unhandled/global error (window.onerror, unhandledrejection);
|
|
17
|
+
* true = a caught error the app chose to report. Defaults true. */
|
|
18
|
+
handled?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** First-touch marketing attribution, parsed once and persisted. */
|
|
22
|
+
export interface Attribution {
|
|
23
|
+
utm: {
|
|
24
|
+
source?: string
|
|
25
|
+
medium?: string
|
|
26
|
+
campaign?: string
|
|
27
|
+
term?: string
|
|
28
|
+
content?: string
|
|
29
|
+
}
|
|
30
|
+
referrer?: string
|
|
31
|
+
refCode?: string
|
|
32
|
+
/** Derived acquisition channel: direct | organic | paid | social | referral. */
|
|
33
|
+
channel?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Cohort dimensions carried on every event once known (see goals.ts COHORTS). */
|
|
37
|
+
export interface Cohort {
|
|
38
|
+
/** ISO week the person first signed up, e.g. "2026-W28". */
|
|
39
|
+
signupWeek?: string
|
|
40
|
+
channel?: string
|
|
41
|
+
refCode?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One event as sent on the wire. tenant/org is NEVER set here — the server
|
|
45
|
+
* stamps it from the validated session. */
|
|
46
|
+
export interface WireEvent {
|
|
47
|
+
messageId: string
|
|
48
|
+
type: EventKind
|
|
49
|
+
event?: string
|
|
50
|
+
timestamp: string
|
|
51
|
+
distinctId?: string
|
|
52
|
+
anonymousId?: string
|
|
53
|
+
personId?: string
|
|
54
|
+
sessionId?: string
|
|
55
|
+
product?: string
|
|
56
|
+
url?: string
|
|
57
|
+
path?: string
|
|
58
|
+
referrer?: string
|
|
59
|
+
utm?: Attribution['utm']
|
|
60
|
+
refCode?: string
|
|
61
|
+
channel?: string
|
|
62
|
+
groupId?: string
|
|
63
|
+
signupWeek?: string
|
|
64
|
+
productId?: string
|
|
65
|
+
quantity?: number
|
|
66
|
+
revenue?: number
|
|
67
|
+
currency?: string
|
|
68
|
+
/** Set on `type:'error'` events — the captured exception. */
|
|
69
|
+
error?: Exception
|
|
70
|
+
properties?: Record<string, unknown>
|
|
71
|
+
library?: string
|
|
72
|
+
libraryVersion?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Injectable transports — overridden in tests; default in core.ts uses fetch. */
|
|
76
|
+
export interface Transport {
|
|
77
|
+
/** Durable POST usable during page unload (fetch keepalive / sendBeacon). */
|
|
78
|
+
send(url: string, body: string, opts: { beacon: boolean; token?: string }): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AnalyticsConfig {
|
|
82
|
+
/** Cloud base URL. Same-origin ("") for cookie-auth apps (console/admin);
|
|
83
|
+
* e.g. "https://api.hanzo.ai" for bearer apps (app/site). */
|
|
84
|
+
host?: string
|
|
85
|
+
/** Emitting surface: console | chat | app | site | admin. */
|
|
86
|
+
product: string
|
|
87
|
+
/** Bearer token provider for token-auth apps. Omit for cookie/session apps
|
|
88
|
+
* (the client then relies on same-origin credentials). */
|
|
89
|
+
getToken?: () => string | undefined | null
|
|
90
|
+
/** Max events buffered before an automatic flush. */
|
|
91
|
+
batchSize?: number
|
|
92
|
+
/** Auto-flush cadence in ms. */
|
|
93
|
+
flushIntervalMs?: number
|
|
94
|
+
/** Turn the client off entirely (e.g. opt-out / DNT). Defaults to enabled. */
|
|
95
|
+
enabled?: boolean
|
|
96
|
+
/** Auto-capture unhandled errors + promise rejections (window.onerror,
|
|
97
|
+
* unhandledrejection) as error events. Browser-only. Defaults to enabled —
|
|
98
|
+
* this is what makes the client a drop-in @sentry replacement. */
|
|
99
|
+
captureErrors?: boolean
|
|
100
|
+
/** Override the transport (tests). */
|
|
101
|
+
transport?: Transport
|
|
102
|
+
/** Debug logging. */
|
|
103
|
+
debug?: boolean
|
|
104
|
+
}
|