@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/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
+ }