feat(telemetry): zero-config @hanzogui/telemetry — one provider, one door, three lenses

Neither gui nor ui exported a telemetry provider, so every app hand-rolled its
own (hanzo.ai wires @hanzo/event by hand; hanzo.app has its own error-logger).
This is the ONE surface, and it needs no configuration:

  <TelemetryProvider>{children}</TelemetryProvider>

That mounts all three planes — errors + session capture, pageviews, and product
events (incl. analytics_errors) — as ONE batched stream to the ONE front door,
POST api.hanzo.ai/v1/event, which Cloud lenses into sentry./analytics./insights.
Those three hosts are dashboards, never ingest endpoints: one API host, so one
thing to configure, and by default nothing.

Composition, mechanism below and policy here:
  @hanzo/event          the client — batching, attribution, the wire
  @hanzo/observe        the capture engine — semantics, redaction, playback
  @hanzogui/telemetry   the zero-config policy that composes them

  - product inferred from the hostname (console.hanzo.ai -> console)
  - env overrides for Next/Vite/Expo plus a window.__HANZO_TELEMETRY__ global
    for pages with no build step
  - pageviews driven by the app's router (`path`) or by the History API, never
    both, so a view is never double-counted
  - capture engine loaded via dynamic import() inside requestIdleCallback, in
    its own chunk — no CDN script, so a `default-src 'none'` CSP cannot break it
  - DNT/GPC honored with no app code; an explicit setConsent() choice outranks
    the browser both ways; nothing touches storage while telemetry is refused
  - SSR-safe (a DOM is required before anything is collected), fail-soft, ESM,
    sideEffects:false
  - a render error is reported and then RE-THROWN when no fallback is given, so
    the app's own error UI still runs — observing must not change behavior

24 tests cover the one-door URL, the three-lens wire shape, DNT/GPC, consent
precedence, SPA route counting, the error boundary, storage-less browsers, and
server rendering.
This commit is contained in:
zeekay
2026-07-25 15:17:50 -07:00
parent 72c577afe4
commit ea757718e8
16 changed files with 1676 additions and 0 deletions
+28
View File
@@ -194,3 +194,31 @@ bugs hide, and a marketing site is visually regression-tested by eye.
<h3 align="center">
Style library, design system, composable components, and more.
</h3>
---
## Telemetry — `@hanzogui/telemetry` (pkgs/telemetry)
The ONE zero-config telemetry surface. `<TelemetryProvider>{children}</TelemetryProvider>`
with no props wires all three planes; `@hanzo/ui/telemetry` re-exports it unchanged
so the component layer needs no second definition.
- **One front door.** Everything (pageviews, product events, exceptions,
interaction capture) is POSTed to `https://api.hanzo.ai/v1/event`. Cloud lenses
that one stream into `sentry.hanzo.ai` (errors + session capture),
`analytics.hanzo.ai` (web analytics) and `insights.hanzo.ai` (product insights,
incl. `analytics_errors`). Those three hosts are DASHBOARDS — never ingest
endpoints, never configured in a client.
- **Layering.** `@hanzo/event` = the client (batching, attribution, the wire).
`@hanzo/observe` = the capture engine (semantics, redaction, playback), loaded
with a dynamic `import()` inside an idle callback so it cannot cost LCP.
`@hanzogui/telemetry` = the zero-config policy that composes them. Mechanism
below, policy here, one of each.
- **Privacy.** DNT/GPC honored by default with no app code; an explicit
`setConsent()` choice outranks the browser in both directions; nothing is
written to storage while telemetry is refused.
- **Guarantees.** SSR-safe (a DOM is required before anything is collected),
fail-soft (every method swallows its own errors), no CDN script, ESM,
`sideEffects: false`.
- Tests: `cd pkgs/telemetry && bun run test` (24 tests — one-door URL, three-lens
wire, DNT/GPC, consent precedence, SPA route counting, error boundary, SSR).
+149
View File
@@ -0,0 +1,149 @@
# @hanzogui/telemetry
Zero-config telemetry for any Hanzo surface. One component, no configuration:
```tsx
import { TelemetryProvider } from '@hanzogui/telemetry'
export default function Root({ children }) {
return <TelemetryProvider>{children}</TelemetryProvider>
}
```
That is the whole adoption. It is also available from the component layer as
`@hanzo/ui/telemetry`, which re-exports this package unchanged — so an app that
already depends on `@hanzo/ui` adds nothing.
## One door, three lenses
Everything — pageviews, product events, exceptions, interaction capture — is
POSTed as one batched stream to the **one** Hanzo front door:
```
POST https://api.hanzo.ai/v1/event { batch: [Event, …] }
```
Cloud lenses that single stream into three views:
| Lens | Host | What it shows |
|---|---|---|
| Errors | `sentry.hanzo.ai` | exceptions, stacks, session capture |
| Web analytics | `analytics.hanzo.ai` | pageviews, referrers, campaigns |
| Product insights | `insights.hanzo.ai` | funnels, notebooks, **`analytics_errors`** |
Errors land in insights too, so a funnel drop joins to the exception that caused
it. Those three hosts are **dashboards**. They are never ingest endpoints: there
is exactly one API host, which is why there is nothing to configure.
## What you get for free
- **Pageviews**, including SPA route changes. Pass `path` (Next: `usePathname()`)
to drive them from your router, or pass nothing and the History API drives them.
- **Errors** — `window.onerror`, unhandled rejections, and React render errors.
This is the `@sentry` replacement; there is no browser Sentry SDK and no CDN
script, so a `default-src 'none'` CSP cannot break it.
- **Session capture** — every click/input/submit annotated with a semantic
hierarchy (`navigation/Dashboard/UserCard/button[save]`), so a replay is
readable rather than a pixel movie. Loaded with a dynamic `import()` inside an
idle callback, in its own chunk, so it never costs LCP.
- **First-touch attribution** and cohorts (`channel`, `refCode`, `signupWeek`)
on every event.
- **Product inference** from the hostname — `console.hanzo.ai` reports as
`console`, `hanzo.chat` as `chat`, `hanzo.ai` as `site`.
## Privacy
- **Do-Not-Track and Global Privacy Control are honored by default.** With
either signal set and no explicit in-app choice, nothing is collected and
nothing is written to storage.
- **Consent-aware.** An explicit choice the person makes in your UI outranks the
browser default in both directions:
```tsx
import { setConsent, useConsent } from '@hanzogui/telemetry'
const choice = useConsent() // 'granted' | 'denied' | undefined
setConsent('denied') // takes effect immediately, everywhere
setConsent('unset') // forget it; DNT/GPC applies again
```
Revocation is prospective: collection stops at once. Events already buffered
under a live consent may complete their in-flight flush.
- **Input values are withheld** from capture by default. `data-hz-private` on any
element excludes its whole subtree.
## Anywhere else
Non-React code does not need the provider — the ambient client builds itself
from the environment on first use, and the provider registers its own client
into the same slot, so there is one instance and one stream either way.
```ts
import { track, identify, captureError, EVENTS } from '@hanzogui/telemetry'
identify(user.id)
track(EVENTS.PLAN_CLICKED, { plan: 'pro' })
try { risky() } catch (err) { captureError(err, { properties: { where: 'checkout' } }) }
```
## In components
```tsx
import { useTelemetry, useTrack } from '@hanzogui/telemetry'
const t = useTelemetry() // total: never null, never throws, SSR-safe
const track = useTrack() // just the recorder
```
## Configuration (all optional)
Environment, read from `process.env` (Next), `import.meta.env` (Vite/Expo), or a
`window.__HANZO_TELEMETRY__` object for pages with no build step:
| Variable | Default | Meaning |
|---|---|---|
| `NEXT_PUBLIC_HANZO_INGEST_KEY` / `VITE_HANZO_INGEST_KEY` | — | Publishable `pk_…` key. Write-only and safe in a bundle; it is what lets a logged-out marketing page light up all three lenses. |
| `NEXT_PUBLIC_HANZO_API_URL` / `VITE_HANZO_API_URL` | `https://api.hanzo.ai` | The one front door. |
| `NEXT_PUBLIC_HANZO_PRODUCT` / `VITE_HANZO_PRODUCT` | inferred from hostname | Emitting surface. |
| `NEXT_PUBLIC_HANZO_TELEMETRY` / `VITE_HANZO_TELEMETRY` | on | `0`/`false`/`off` is a build-time kill switch. |
Props, when you want them:
```tsx
<TelemetryProvider
path={usePathname()} // drive pageviews from your router
product="console" // override the inferred surface
host="" // same-origin, cookie-auth apps
getToken={() => token} // bearer-auth apps
replay={false} // no interaction capture
errors={false} // no error capture
pageviews={false} // no pageviews
consent="granted" // your own consent gate decided
enabled={false} // hard kill switch, wins over everything
fallback={(err, reset) => <Crash error={err} onReset={reset} />}
/>
```
With no `fallback`, a render error is reported and then **re-thrown** so your own
error UI still runs. Observing must not change behavior.
## Guarantees
- **SSR-safe.** Importing does nothing; rendering on the server does nothing. A
DOM is required before anything is collected — React Native hosts stay silent
rather than emit half an event.
- **Fail-soft.** Every public method swallows its own errors, network loss is
ignored, and a failed capture-engine chunk simply means no capture.
- **Tree-shakable.** `sideEffects: false`, ESM, one export per concern.
- **No CDN.** Nothing is loaded from a third-party origin, ever.
## Composition
```
@hanzo/event the ONE client — batching, attribution, the wire, the door
@hanzo/observe the capture engine — semantics, redaction, playback stream
@hanzogui/telemetry ← this: the zero-config policy that wires them together
@hanzo/ui/telemetry re-export, so the component layer needs no new dependency
```
Mechanism lives below, policy lives here, and there is exactly one of each.
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@hanzogui/telemetry",
"version": "0.1.0",
"description": "Zero-config Hanzo telemetry — one <TelemetryProvider/> wires errors + session capture, pageviews, and product events to the ONE front door (POST api.hanzo.ai/v1/event), which lenses them into sentry.hanzo.ai, analytics.hanzo.ai and insights.hanzo.ai. Honors DNT/GPC, consent-aware, SSR-safe, fail-soft.",
"source": "src/index.ts",
"files": [
"dist",
"src",
"README.md"
],
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"clean": "rm -rf dist",
"prepublishOnly": "tsc -p tsconfig.json"
},
"dependencies": {
"@hanzo/event": "^0.3.1",
"@hanzo/observe": "^0.1.0"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^20.10.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.9.3",
"vitest": "^4.1.9"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"telemetry",
"analytics",
"error-tracking",
"session-replay",
"consent",
"react",
"hanzo"
],
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/gui",
"directory": "pkgs/telemetry"
}
}
+224
View File
@@ -0,0 +1,224 @@
'use client'
// The one-liner.
//
// import { TelemetryProvider } from '@hanzo/gui/telemetry'
// <TelemetryProvider>{children}</TelemetryProvider>
//
// That mounts all three planes with no configuration:
//
// errors + session capture → sentry.hanzo.ai
// pageviews → analytics.hanzo.ai
// product events + errors → insights.hanzo.ai (analytics_errors, so a funnel
// drop joins to the exception behind it)
//
// One stream reaches all three: every event is POSTed to the ONE front door
// (api.hanzo.ai/v1/event) and lensed server-side. Three views, one datastore,
// one thing to configure — and by default, nothing to configure.
import {
Component,
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ErrorInfo,
type ReactNode,
} from 'react'
import { AnalyticsProvider } from '@hanzo/event/react'
import { getConsent, onConsentChange } from './consent.js'
import { createTelemetry, getTelemetry, setTelemetry } from './telemetry.js'
import { useReplay } from './useReplay.js'
import { useRouteTracking } from './useRouteTracking.js'
import type { Telemetry, TelemetryConfig } from './types.js'
const TelemetryContext = createContext<Telemetry | null>(null)
export interface TelemetryProviderProps extends TelemetryConfig {
/** The current route. Pass `usePathname()` (Next) or your router's location to
* drive pageviews from the app; omit it and the History API drives them. */
path?: string | null
/** Rendered instead of the crashed subtree. With no fallback the error is
* re-thrown after being reported, so the app's own error UI still runs — this
* provider observes crashes, it never swallows them. */
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
/** A pre-built client. Rarely needed; the provider builds one for you. */
client?: Telemetry
children: ReactNode
}
/** TelemetryProvider wires the whole telemetry surface for its subtree. Safe to
* render on the server (it does nothing there) and safe to render twice. */
export function TelemetryProvider(props: TelemetryProviderProps): ReactNode {
const {
path,
fallback,
client,
children,
host,
product,
ingestKey,
getToken,
enabled,
consent,
replay,
errors,
pageviews,
redaction,
debug,
} = props
// A caller's `getToken` is usually an inline closure; keep the identity we
// hand the client stable so the client is not rebuilt on every render.
const tokenRef = useRef(getToken)
tokenRef.current = getToken
const redactionRef = useRef(redaction)
redactionRef.current = redaction
// Consent can change at runtime (a banner, a settings toggle, another tab).
// Re-resolving means the client stops or starts collecting immediately.
const [consentEpoch, setConsentEpoch] = useState(0)
useEffect(() => {
const bump = (): void => setConsentEpoch((n) => n + 1)
const off = onConsentChange(bump)
const onStorage = (e: StorageEvent): void => {
if (e.key === null || e.key === 'hz_consent') bump()
}
window.addEventListener('storage', onStorage)
return () => {
off()
window.removeEventListener('storage', onStorage)
}
}, [])
const telemetry = useMemo<Telemetry>(
() =>
client ??
createTelemetry({
host,
product,
ingestKey,
getToken: tokenRef.current ? () => tokenRef.current?.() : undefined,
enabled,
consent,
replay,
errors,
pageviews,
debug,
}),
[
client,
host,
product,
ingestKey,
enabled,
consent,
replay,
errors,
pageviews,
debug,
// Rebuilding on a consent change is the point: `enabled` is baked into the
// client, and a fresh one collects (or refuses to collect) accordingly.
consentEpoch,
],
)
// Module-scope `track()` and this tree share one client and one stream.
useEffect(() => {
setTelemetry(telemetry)
telemetry.client.init()
return () => {
if (getTelemetry() === telemetry) setTelemetry(undefined)
}
}, [telemetry])
const active = telemetry.enabled
useRouteTracking(telemetry, active && (pageviews ?? true), path)
useReplay(telemetry, active && (replay ?? true), redactionRef.current)
return (
<TelemetryContext.Provider value={telemetry}>
{/* Interop: any @hanzo/* component that reads `useAnalytics()` gets THIS
client. Pageviews are ours, so the built-in one is turned off. */}
<AnalyticsProvider client={telemetry.client} autoPageview={false}>
<TelemetryBoundary
telemetry={telemetry}
enabled={active && (errors ?? true)}
fallback={fallback}
>
{children}
</TelemetryBoundary>
</AnalyticsProvider>
</TelemetryContext.Provider>
)
}
/** useTelemetry returns the client for the current tree. Outside a provider it
* returns the ambient one, so it is total — it never throws and never returns
* null, in a test, on the server, or in a component rendered standalone. */
export function useTelemetry(): Telemetry {
const fromTree = useContext(TelemetryContext)
const ambient = useMemo(() => getTelemetry(), [])
return fromTree ?? ambient
}
/** useTrack is the narrow hook: just the event recorder, stable across renders. */
export function useTrack(): Telemetry['track'] {
return useTelemetry().track
}
interface BoundaryProps {
telemetry: Telemetry
enabled: boolean
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
children: ReactNode
}
interface BoundaryState {
error: Error | null
}
/** TelemetryBoundary reports React render errors — the ONE class of error that
* `window.onerror` never sees, so a boundary is the only way to observe them.
*
* It reports and then gets out of the way: with no `fallback` the error is
* re-thrown so the app's own boundary (Next's `error.tsx`, an outer boundary)
* still decides what the user sees. Observing must not change behavior. */
export class TelemetryBoundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { error: null }
static getDerivedStateFromError(error: Error): BoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
if (!this.props.enabled) return
this.props.telemetry.captureError(error, {
handled: false,
properties: { componentStack: info.componentStack, react: true },
})
}
private reset = (): void => this.setState({ error: null })
render(): ReactNode {
const { error } = this.state
if (!error) return this.props.children
const { fallback } = this.props
if (fallback === undefined) throw error
return typeof fallback === 'function' ? fallback(error, this.reset) : fallback
}
}
/** useConsent reads the person's stored choice and re-reads it on change — the
* state a consent banner renders from. */
export function useConsent(): 'granted' | 'denied' | undefined {
const [value, setValue] = useState<'granted' | 'denied' | undefined>(undefined)
useEffect(() => {
setValue(getConsent())
return onConsentChange(setValue)
}, [])
return value
}
+105
View File
@@ -0,0 +1,105 @@
// Consent, in one place.
//
// Two independent signals, one answer:
//
// 1. The browser's standing preference — Do-Not-Track / Global Privacy
// Control. Honored by default, with no code in the app.
// 2. The person's explicit in-app choice, persisted. A choice they made
// themselves outranks the browser default in BOTH directions — that is
// what "explicit" means.
//
// An app that wants no persistence at all just passes `consent="granted"` or
// `consent="denied"` and never calls `setConsent`.
import type { StoredConsent, TelemetryConsent } from './types.js'
const KEY = 'hz_consent'
const hasWindow = (): boolean => typeof window !== 'undefined'
function store(): Storage | undefined {
try {
if (!hasWindow() || !window.localStorage) return undefined
return window.localStorage
} catch {
return undefined // Safari private mode / blocked storage
}
}
/** True when the browser is asking not to be tracked — DNT in any of its three
* historical spellings, or the Global Privacy Control signal. */
export function doNotTrack(): boolean {
if (!hasWindow()) return false
try {
const n = navigator as Navigator & {
msDoNotTrack?: string
globalPrivacyControl?: boolean
}
const w = window as Window & { doNotTrack?: string }
if (n.globalPrivacyControl === true) return true
return n.doNotTrack === '1' || w.doNotTrack === '1' || n.msDoNotTrack === '1'
} catch {
return false
}
}
/** The person's persisted choice, or undefined if they never made one. */
export function getConsent(): StoredConsent | undefined {
const s = store()
if (!s) return undefined
try {
const v = s.getItem(KEY)
return v === 'granted' || v === 'denied' ? v : undefined
} catch {
return undefined
}
}
type Listener = (consent: StoredConsent | undefined) => void
const listeners = new Set<Listener>()
/** Record the person's choice. `'unset'` forgets it, so the `auto` policy
* (DNT/GPC) applies again. Live providers re-evaluate immediately. */
export function setConsent(consent: StoredConsent | 'unset'): void {
const s = store()
try {
if (consent === 'unset') s?.removeItem(KEY)
else s?.setItem(KEY, consent)
} catch {
/* storage blocked — the in-memory notification below still applies */
}
const next = consent === 'unset' ? undefined : consent
for (const fn of listeners) {
try {
fn(next)
} catch {
/* a listener must never break the caller */
}
}
}
/** Subscribe to consent changes; returns the unsubscribe. */
export function onConsentChange(fn: Listener): () => void {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
}
/** The single policy decision. `enabled` is a hard build-time override and wins
* over everything; otherwise an explicit stored choice wins; otherwise the
* declared posture; otherwise DNT/GPC. */
export function resolveEnabled(opts: {
enabled?: boolean
consent?: TelemetryConsent
stored?: StoredConsent | undefined
}): boolean {
if (typeof opts.enabled === 'boolean') return opts.enabled
const stored = opts.stored !== undefined ? opts.stored : getConsent()
if (stored === 'denied') return false
if (stored === 'granted') return true
const mode: TelemetryConsent = opts.consent ?? 'auto'
if (mode === 'denied') return false
if (mode === 'granted') return true
return !doNotTrack()
}
+166
View File
@@ -0,0 +1,166 @@
// Zero-config resolution: where telemetry goes and what it calls itself, with
// nobody having configured anything.
//
// THE ONE FRONT DOOR. Everything a Hanzo surface emits — pageviews, product
// events, exceptions, interaction capture — is POSTed to
//
// POST https://api.hanzo.ai/v1/event { batch: [Event, …] }
//
// and Cloud lenses that single stream into the three views:
//
// sentry.hanzo.ai errors + stacks + session capture
// analytics.hanzo.ai web analytics (pageviews, referrers, campaigns)
// insights.hanzo.ai product insights + notebooks (incl. analytics_errors,
// so a funnel drop joins to the exception that caused it)
//
// Those are dashboards — FRONTENDS. They are never ingest endpoints. There is
// exactly one API host, so there is exactly one thing to configure, and by
// default you configure nothing.
const DEFAULT_HOST = 'https://api.hanzo.ai'
// Declared locally rather than pulling @types/node into a browser package: the
// only thing this package wants from Node is `process.env`, and only when a
// bundler has inlined it.
declare const process: { env?: Record<string, string | undefined> } | undefined
/** The keys this package understands, sans prefix. */
interface RawEnv {
apiUrl?: string
ingestKey?: string
product?: string
/** "0" | "false" | "off" disables telemetry entirely. */
telemetry?: string
debug?: string
}
/** A runtime override for hosts with no build step (a static page can set
* `window.__HANZO_TELEMETRY__ = { ingestKey: 'pk_…' }` from a script tag). */
export interface TelemetryGlobal {
apiUrl?: string
host?: string
ingestKey?: string
product?: string
enabled?: boolean
debug?: boolean
}
const first = (...vals: Array<string | undefined>): string | undefined => {
for (const v of vals) {
if (typeof v === 'string' && v.trim() !== '') return v.trim()
}
return undefined
}
/** `process.env.X` and `import.meta.env.X` must be written as LITERAL member
* expressions or no bundler will inline them — hence the explicit table. This
* is the only place in the package that reads the environment. */
function processEnv(): RawEnv {
try {
if (typeof process === 'undefined' || !process.env) return {}
const e = process.env
return {
apiUrl: first(e.NEXT_PUBLIC_HANZO_API_URL, e.PUBLIC_HANZO_API_URL, e.HANZO_API_URL),
ingestKey: first(e.NEXT_PUBLIC_HANZO_INGEST_KEY, e.PUBLIC_HANZO_INGEST_KEY, e.HANZO_INGEST_KEY),
product: first(e.NEXT_PUBLIC_HANZO_PRODUCT, e.PUBLIC_HANZO_PRODUCT, e.HANZO_PRODUCT),
telemetry: first(e.NEXT_PUBLIC_HANZO_TELEMETRY, e.PUBLIC_HANZO_TELEMETRY, e.HANZO_TELEMETRY),
debug: first(e.NEXT_PUBLIC_HANZO_TELEMETRY_DEBUG, e.PUBLIC_HANZO_TELEMETRY_DEBUG),
}
} catch {
return {}
}
}
function metaEnv(): RawEnv {
try {
const m = import.meta as unknown as { env?: Record<string, string | undefined> }
const e = m?.env
if (!e) return {}
return {
apiUrl: first(e.VITE_HANZO_API_URL, e.EXPO_PUBLIC_HANZO_API_URL, e.PUBLIC_HANZO_API_URL),
ingestKey: first(e.VITE_HANZO_INGEST_KEY, e.EXPO_PUBLIC_HANZO_INGEST_KEY, e.PUBLIC_HANZO_INGEST_KEY),
product: first(e.VITE_HANZO_PRODUCT, e.EXPO_PUBLIC_HANZO_PRODUCT, e.PUBLIC_HANZO_PRODUCT),
telemetry: first(e.VITE_HANZO_TELEMETRY, e.EXPO_PUBLIC_HANZO_TELEMETRY, e.PUBLIC_HANZO_TELEMETRY),
debug: first(e.VITE_HANZO_TELEMETRY_DEBUG, e.EXPO_PUBLIC_HANZO_TELEMETRY_DEBUG),
}
} catch {
return {}
}
}
function globalEnv(): TelemetryGlobal {
try {
const g = globalThis as unknown as { __HANZO_TELEMETRY__?: TelemetryGlobal }
return g.__HANZO_TELEMETRY__ ?? {}
} catch {
return {}
}
}
/** Surfaces whose product name is not simply their first hostname label. */
const PRODUCT_BY_HOST: Readonly<Record<string, string>> = {
'hanzo.ai': 'site',
'hanzo.chat': 'chat',
'hanzo.app': 'app',
'hanzo.id': 'iam',
'hanzo.industries': 'industries',
'hanzo.network': 'network',
}
/** Product inferred from where the page is served — the reason you can ship
* `<TelemetryProvider/>` with no props and still get correctly-labelled data. */
export function productFromHost(hostname: string | undefined): string | undefined {
if (!hostname) return undefined
const h = hostname.toLowerCase().replace(/^www\./, '')
if (h === 'localhost' || h === '::1' || h === '[::1]' || /^127\./.test(h)) return 'dev'
const mapped = PRODUCT_BY_HOST[h]
if (mapped) return mapped
// `console.hanzo.ai` → console, `cloud.hanzo.ai` → cloud, `zoo.ngo` → zoo.
const label = h.split('.')[0]
return label && label !== '' ? label : undefined
}
const isOff = (v: string | undefined): boolean =>
v === '0' || v === 'false' || v === 'off' || v === 'no'
const isOn = (v: string | undefined): boolean =>
v === '1' || v === 'true' || v === 'on' || v === 'yes'
/** What the environment says, already merged and normalized. */
export interface ResolvedEnv {
host: string
product?: string
ingestKey?: string
/** Present only when the environment explicitly turned telemetry on or off. */
enabled?: boolean
debug: boolean
}
/** resolveEnv merges, in increasing precedence: built-in defaults, the build
* environment (`process.env` / `import.meta.env`), and a runtime global. It
* never throws and never touches the DOM. */
export function resolveEnv(): ResolvedEnv {
const p = processEnv()
const m = metaEnv()
const g = globalEnv()
const telemetryFlag = first(m.telemetry, p.telemetry)
const enabled =
typeof g.enabled === 'boolean'
? g.enabled
: isOff(telemetryFlag)
? false
: isOn(telemetryFlag)
? true
: undefined
return {
host: first(g.host, g.apiUrl, m.apiUrl, p.apiUrl) ?? DEFAULT_HOST,
product: first(g.product, m.product, p.product),
ingestKey: first(g.ingestKey, m.ingestKey, p.ingestKey),
enabled,
debug: g.debug === true || isOn(first(m.debug, p.debug)),
}
}
export { DEFAULT_HOST }
+57
View File
@@ -0,0 +1,57 @@
// @hanzogui/telemetry — the ONE telemetry surface for Hanzo apps.
//
// <TelemetryProvider>{children}</TelemetryProvider> // zero config
// const t = useTelemetry(); t.track('plan_clicked') // in components
// import { track } from '@hanzogui/telemetry' // anywhere else
//
// One provider, one client, one stream to the ONE front door
// (POST api.hanzo.ai/v1/event), lensed server-side into three views:
// sentry.hanzo.ai (errors + session capture), analytics.hanzo.ai (pageviews),
// insights.hanzo.ai (product events + analytics_errors).
//
// Honors Do-Not-Track and Global Privacy Control, consent-aware, SSR-safe,
// side-effect-free on import, and fail-soft: it can never break the host app.
export { TelemetryProvider, TelemetryBoundary, useTelemetry, useTrack, useConsent } from './TelemetryProvider.js'
export type { TelemetryProviderProps } from './TelemetryProvider.js'
export {
createTelemetry,
describeTelemetry,
getTelemetry,
setTelemetry,
hasDom,
track,
pageview,
identify,
group,
captureError,
captureException,
flush,
} from './telemetry.js'
export type { ResolvedTelemetry } from './telemetry.js'
export { doNotTrack, getConsent, setConsent, onConsentChange, resolveEnabled } from './consent.js'
export { productFromHost, resolveEnv, DEFAULT_HOST } from './env.js'
export type { ResolvedEnv, TelemetryGlobal } from './env.js'
export { useRouteTracking } from './useRouteTracking.js'
export { useReplay } from './useReplay.js'
export type {
Analytics,
Exception,
RedactionPolicy,
StoredConsent,
Telemetry,
TelemetryCommerce,
TelemetryConfig,
TelemetryConsent,
TelemetryErrorContext,
} from './types.js'
// The shared event + goal vocabulary, so a surface never invents its own names
// for a signup or a sale. One import for the whole telemetry story.
export { EVENTS, GOALS, COHORTS, PAGEVIEW } from '@hanzo/event'
export type { EventName, GoalDef, CohortDef } from '@hanzo/event'
+152
View File
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
//
// The adoption claim, tested: mounting `<TelemetryProvider>` with NO props is
// the entire setup. A pageview ships, a route change ships another, a render
// error is reported, and the ambient `track()` shares the provider's stream.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { TelemetryProvider } from './TelemetryProvider.js'
import { getTelemetry, setTelemetry, track } from './telemetry.js'
interface Sent {
url: string
batch: Array<Record<string, unknown>>
}
let sent: Sent[]
let container: HTMLDivElement
let root: Root
function installStorage(): void {
const map = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
get length() {
return map.size
},
clear: () => map.clear(),
getItem: (k: string) => map.get(k) ?? null,
key: (i: number) => Array.from(map.keys())[i] ?? null,
removeItem: (k: string) => void map.delete(k),
setItem: (k: string, v: string) => void map.set(k, String(v)),
} satisfies Storage,
})
}
const events = (): Array<Record<string, unknown>> => sent.flatMap((s) => s.batch)
beforeEach(() => {
;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
installStorage()
vi.stubGlobal('navigator', {})
setTelemetry(undefined)
sent = []
vi.stubGlobal('fetch', (url: string, init?: { body?: string }) => {
const body = JSON.parse(init?.body ?? '{"batch":[]}') as {
batch: Array<Record<string, unknown>>
}
sent.push({ url, batch: body.batch })
return Promise.resolve({ ok: true } as Response)
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
describe('<TelemetryProvider/> with no props', () => {
it('renders children and records the initial pageview', () => {
act(() => {
root.render(
<TelemetryProvider replay={false}>
<p>hello</p>
</TelemetryProvider>,
)
})
expect(container.textContent).toBe('hello')
act(() => getTelemetry().flush())
const pageviews = events().filter((e) => e.type === 'pageview')
expect(pageviews).toHaveLength(1)
expect(sent[0]!.url).toBe('https://api.hanzo.ai/v1/event')
})
it('counts a SPA route change with no app code', () => {
act(() => {
root.render(
<TelemetryProvider replay={false}>
<p>hi</p>
</TelemetryProvider>,
)
})
act(() => {
window.history.pushState({}, '', '/pricing')
})
act(() => getTelemetry().flush())
const paths = events()
.filter((e) => e.type === 'pageview')
.map((e) => e.path)
expect(paths).toContain('/pricing')
expect(paths).toHaveLength(2) // initial + the navigation, never double-counted
})
it('shares ONE stream with module-scope track()', () => {
act(() => {
root.render(
<TelemetryProvider product="site" replay={false}>
<p>hi</p>
</TelemetryProvider>,
)
})
track('plan_clicked', { plan: 'pro' })
act(() => getTelemetry().flush())
const evt = events().find((e) => e.event === 'plan_clicked')
expect(evt).toBeDefined()
expect(evt!.product).toBe('site') // the provider's client, not a second one
})
it('reports a React render error and shows the fallback', () => {
const Boom = (): never => {
throw new Error('render exploded')
}
vi.spyOn(console, 'error').mockImplementation(() => {})
act(() => {
root.render(
<TelemetryProvider replay={false} fallback={<p>sorry</p>}>
<Boom />
</TelemetryProvider>,
)
})
expect(container.textContent).toBe('sorry')
act(() => getTelemetry().flush())
const err = events().find((e) => e.type === 'error') as
| { error: Record<string, unknown>; properties: Record<string, unknown> }
| undefined
expect(err?.error).toMatchObject({ message: 'render exploded', handled: false })
expect(err?.properties).toMatchObject({ react: true })
})
it('collects nothing when the browser asks not to be tracked', () => {
vi.stubGlobal('navigator', { globalPrivacyControl: true })
act(() => {
root.render(
<TelemetryProvider replay={false}>
<p>hi</p>
</TelemetryProvider>,
)
})
act(() => getTelemetry().flush())
expect(sent).toHaveLength(0)
})
})
+35
View File
@@ -0,0 +1,35 @@
// @vitest-environment node
//
// SSR safety, proven where it matters: a Node environment with no window, no
// document and no localStorage. The provider must render its children, collect
// nothing, and never throw — importing telemetry into a server component has to
// be a non-event.
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { TelemetryProvider } from './TelemetryProvider.js'
import { createTelemetry, hasDom, track } from './telemetry.js'
describe('server rendering', () => {
it('has no DOM to speak of', () => {
expect(typeof window).toBe('undefined')
expect(hasDom()).toBe(false)
})
it('renders children and collects nothing', () => {
const html = renderToString(
<TelemetryProvider>
<p>hello</p>
</TelemetryProvider>,
)
expect(html).toContain('hello')
})
it('is inert: a client built on the server is disabled', () => {
expect(createTelemetry().enabled).toBe(false)
})
it('does not throw when the ambient API is called on the server', () => {
expect(() => track('server_side_call', { a: 1 })).not.toThrow()
})
})
+243
View File
@@ -0,0 +1,243 @@
// @vitest-environment happy-dom
//
// What these tests actually prove, end to end:
//
// 1. With NO configuration, events reach exactly ONE URL —
// https://api.hanzo.ai/v1/event — and nothing else is ever contacted.
// 2. All three lenses are fed by that one stream: a pageview, a product event
// and an exception all appear in the same batch with the right `type`, so
// Cloud can fan them out to analytics / insights / sentry.
// 3. Do-Not-Track and Global Privacy Control are honored with zero app code,
// and an explicit stored choice outranks them in both directions.
// 4. Nothing is written to storage while telemetry is refused.
// 5. Nothing ever throws into the host app — no network, no storage, no
// well-formed error required.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { onConsentChange, setConsent } from './consent.js'
import { productFromHost, resolveEnv } from './env.js'
import { createTelemetry, setTelemetry } from './telemetry.js'
interface Sent {
url: string
batch: Array<Record<string, unknown>>
}
let sent: Sent[]
/** happy-dom ships no localStorage, which is also the Safari-private-mode case
* the client must survive — so the tests install a real one explicitly when
* they mean to exercise persistence, and remove it when they do not. */
function installStorage(): Storage {
const map = new Map<string, string>()
const storage: Storage = {
get length() {
return map.size
},
clear: () => map.clear(),
getItem: (k: string) => map.get(k) ?? null,
key: (i: number) => Array.from(map.keys())[i] ?? null,
removeItem: (k: string) => void map.delete(k),
setItem: (k: string, v: string) => void map.set(k, String(v)),
}
Object.defineProperty(window, 'localStorage', { value: storage, configurable: true })
return storage
}
function stubNavigator(patch: Record<string, unknown>): void {
vi.stubGlobal('navigator', patch)
}
function captureFetch(): void {
sent = []
vi.stubGlobal('fetch', (url: string, init?: { body?: string }) => {
const body = JSON.parse(init?.body ?? '{"batch":[]}') as {
batch: Array<Record<string, unknown>>
}
sent.push({ url, batch: body.batch })
return Promise.resolve({ ok: true } as Response)
})
}
beforeEach(() => {
installStorage().clear()
stubNavigator({})
setTelemetry(undefined)
captureFetch()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('the one front door', () => {
it('defaults to https://api.hanzo.ai/v1/event with no configuration', () => {
expect(resolveEnv().host).toBe('https://api.hanzo.ai')
const t = createTelemetry()
expect(t.enabled).toBe(true)
t.track('plan_clicked', { plan: 'pro' })
t.flush()
expect(sent).toHaveLength(1)
expect(sent[0]!.url).toBe('https://api.hanzo.ai/v1/event')
})
it('carries all three lenses on ONE stream', () => {
const t = createTelemetry({ product: 'site' })
t.pageview('/pricing') // → analytics.hanzo.ai
t.track('plan_clicked', { plan: 'pro' }) // → insights.hanzo.ai
t.identify('user-42')
t.captureError(new TypeError('boom')) // → sentry.hanzo.ai (+ analytics_errors)
t.flush()
const batch = sent.flatMap((s) => s.batch)
const kinds = batch.map((e) => e.type)
expect(kinds).toContain('pageview')
expect(kinds).toContain('event')
expect(kinds).toContain('identify')
expect(kinds).toContain('error')
// Every event names the same emitting surface and rides the same wire.
expect(new Set(batch.map((e) => e.product))).toEqual(new Set(['site']))
expect(new Set(sent.map((s) => s.url))).toEqual(new Set(['https://api.hanzo.ai/v1/event']))
// A reported error is `handled: true`; only global/unhandled capture is false.
const err = batch.find((e) => e.type === 'error') as { error: Record<string, unknown> }
expect(err.error).toMatchObject({ type: 'TypeError', message: 'boom', handled: true })
})
it('never contacts sentry./analytics./insights. hosts — those are dashboards', () => {
const t = createTelemetry()
t.track('x')
t.captureError(new Error('y'))
t.flush()
expect(sent.length).toBeGreaterThan(0)
for (const s of sent) {
expect(s.url).not.toMatch(/sentry\.hanzo\.ai|analytics\.hanzo\.ai|insights\.hanzo\.ai/)
}
})
it('sends a publishable key as a bearer so a logged-out page still reports', () => {
const headers: Array<Record<string, string>> = []
vi.stubGlobal('fetch', (_url: string, init?: { headers?: Record<string, string> }) => {
headers.push(init?.headers ?? {})
return Promise.resolve({ ok: true } as Response)
})
const t = createTelemetry({ ingestKey: 'pk_live_test' })
t.track('x')
t.flush()
expect(headers[0]?.Authorization).toBe('Bearer pk_live_test')
})
})
describe('zero configuration', () => {
it('infers the emitting surface from the host', () => {
expect(productFromHost('hanzo.ai')).toBe('site')
expect(productFromHost('www.hanzo.ai')).toBe('site')
expect(productFromHost('console.hanzo.ai')).toBe('console')
expect(productFromHost('cloud.hanzo.ai')).toBe('cloud')
expect(productFromHost('hanzo.chat')).toBe('chat')
expect(productFromHost('hanzo.app')).toBe('app')
expect(productFromHost('zoo.ngo')).toBe('zoo')
expect(productFromHost('localhost')).toBe('dev')
expect(productFromHost(undefined)).toBeUndefined()
})
it('reads a runtime global for pages with no build step', () => {
;(globalThis as unknown as Record<string, unknown>).__HANZO_TELEMETRY__ = {
ingestKey: 'pk_from_script_tag',
product: 'docs',
}
try {
const env = resolveEnv()
expect(env.ingestKey).toBe('pk_from_script_tag')
expect(createTelemetry().product).toBe('docs')
} finally {
delete (globalThis as unknown as Record<string, unknown>).__HANZO_TELEMETRY__
}
})
})
describe('privacy', () => {
it('honors Global Privacy Control with no app code', () => {
stubNavigator({ globalPrivacyControl: true })
const t = createTelemetry()
expect(t.enabled).toBe(false)
t.track('should_not_ship')
t.flush()
expect(sent).toHaveLength(0)
})
it('honors Do-Not-Track with no app code', () => {
stubNavigator({ doNotTrack: '1' })
expect(createTelemetry().enabled).toBe(false)
})
it('lets an explicit choice outrank the browser default, both ways', () => {
stubNavigator({ doNotTrack: '1' })
setConsent('granted')
expect(createTelemetry().enabled).toBe(true)
setConsent('denied')
expect(createTelemetry().enabled).toBe(false)
setConsent('unset') // back to honoring the browser
expect(createTelemetry().enabled).toBe(false)
})
it('writes nothing to storage while telemetry is refused', () => {
stubNavigator({ doNotTrack: '1' })
const t = createTelemetry()
t.track('x')
t.pageview('/')
t.flush()
expect(window.localStorage.length).toBe(0)
})
it('respects a build-time kill switch over everything', () => {
setConsent('granted')
expect(createTelemetry({ enabled: false }).enabled).toBe(false)
})
it('notifies live listeners the moment consent changes', () => {
const seen: Array<string | undefined> = []
const off = onConsentChange((c) => seen.push(c))
setConsent('denied')
setConsent('granted')
off()
setConsent('denied')
expect(seen).toEqual(['denied', 'granted'])
})
})
describe('fail-soft', () => {
it('swallows a transport that throws', () => {
vi.stubGlobal('fetch', () => {
throw new Error('network is down')
})
const t = createTelemetry()
expect(() => {
t.track('x')
t.flush()
}).not.toThrow()
})
it('swallows a non-Error thrown value', () => {
const t = createTelemetry()
expect(() => t.captureError({ weird: true })).not.toThrow()
t.flush()
const err = sent.flatMap((s) => s.batch).find((e) => e.type === 'error')
expect(err).toBeDefined()
})
it('works with no localStorage at all (Safari private mode)', () => {
Object.defineProperty(window, 'localStorage', { value: undefined, configurable: true })
const t = createTelemetry()
expect(() => {
t.track('x')
t.flush()
}).not.toThrow()
expect(sent[0]?.url).toBe('https://api.hanzo.ai/v1/event')
})
})
+159
View File
@@ -0,0 +1,159 @@
// The framework-agnostic half: build a Telemetry, or reach for the ambient one.
//
// import { track } from '@hanzogui/telemetry'
// track('checkout_started', { plan: 'pro' })
//
// No provider, no config, no init call. The first `track` lazily builds the
// ambient client from the environment; `<TelemetryProvider/>` registers its own
// client into the same slot, so React and non-React code emit through ONE
// instance and one stream.
import { createAnalytics } from '@hanzo/event'
import type { Analytics } from '@hanzo/event'
import { resolveEnabled } from './consent.js'
import { productFromHost, resolveEnv } from './env.js'
import type { Telemetry, TelemetryCommerce, TelemetryConfig, TelemetryErrorContext } from './types.js'
/** A DOM — not merely a `window`. React Native defines a global `window` with
* no `location`/`document`, and SSR defines neither; both must stay silent. */
export const hasDom = (): boolean =>
typeof window !== 'undefined' && typeof document !== 'undefined'
/** Never let a telemetry call throw into the caller. This is the ONLY error
* policy in the package: every public method funnels through it. */
function soft(fn: () => void, debug?: boolean): void {
try {
fn()
} catch (err) {
if (debug) console.debug('[telemetry] swallowed', err)
}
}
/** The fully-resolved settings behind a Telemetry — useful in tests and in a
* debug overlay; returned by `describeTelemetry`. */
export interface ResolvedTelemetry {
host: string
product: string
enabled: boolean
replay: boolean
errors: boolean
pageviews: boolean
hasIngestKey: boolean
debug: boolean
}
const resolved = new WeakMap<object, ResolvedTelemetry>()
/** describeTelemetry exposes what a client actually resolved to. */
export function describeTelemetry(t: Telemetry): ResolvedTelemetry | undefined {
return resolved.get(t)
}
/** createTelemetry builds an isolated client. Most surfaces never call this —
* they mount `<TelemetryProvider/>` or just `track()`. It is pure: no DOM
* access beyond reading `location.hostname`, no listeners, no requests. */
export function createTelemetry(config: TelemetryConfig = {}): Telemetry {
const env = resolveEnv()
const debug = config.debug ?? env.debug
const product =
config.product ??
env.product ??
(hasDom() ? productFromHost(window.location?.hostname) : undefined) ??
'unknown'
// A DOM is a precondition: the client reads location/referrer and buffers to
// an unload beacon. Server and native hosts stay silent rather than emit half
// an event. `enabled` (a build-time kill switch) still wins over consent.
const permitted = hasDom() && resolveEnabled({ enabled: config.enabled ?? env.enabled, consent: config.consent })
const errors = config.errors ?? true
const replay = config.replay ?? true
const pageviews = config.pageviews ?? true
const client: Analytics = createAnalytics({
host: config.host !== undefined ? config.host : env.host,
product,
ingestKey: config.ingestKey ?? env.ingestKey,
getToken: config.getToken,
enabled: permitted,
// The provider owns pageviews; @hanzo/event owns global error capture.
captureErrors: permitted && errors,
debug,
})
const telemetry: Telemetry = {
enabled: permitted,
product,
client,
track: (event, properties, commerce?: TelemetryCommerce) =>
soft(() => client.capture(event, properties, commerce), debug),
pageview: (path, properties) => soft(() => client.pageview(path, properties), debug),
identify: (personId, traits) => soft(() => client.identify(personId, traits), debug),
group: (groupId, traits) => soft(() => client.group(groupId, traits), debug),
captureError: (err, context?: TelemetryErrorContext) =>
soft(() => client.captureError(err, context), debug),
captureException: (err, context?: TelemetryErrorContext) =>
soft(() => client.captureError(err, context), debug),
setCohort: (patch) => soft(() => client.setCohort(patch), debug),
flush: () => soft(() => client.flush(), debug),
}
resolved.set(telemetry, {
host: config.host !== undefined ? config.host : env.host,
product,
enabled: permitted,
replay,
errors,
pageviews,
hasIngestKey: Boolean(config.ingestKey ?? env.ingestKey),
debug,
})
return telemetry
}
let ambient: Telemetry | undefined
/** getTelemetry returns the ambient client, building it from the environment on
* first use. This is what makes `track()` work with zero setup. */
export function getTelemetry(): Telemetry {
if (!ambient) ambient = createTelemetry()
return ambient
}
/** setTelemetry installs a client as the ambient one. `<TelemetryProvider/>`
* calls this so module-scope `track()` and the React tree share one stream. */
export function setTelemetry(t: Telemetry | undefined): void {
ambient = t
}
// ── The ambient API. Import and call; nothing to wire. ────────────────────────
/** Record a named product event → insights.hanzo.ai. */
export const track = (
event: string,
properties?: Record<string, unknown>,
commerce?: TelemetryCommerce,
): void => getTelemetry().track(event, properties, commerce)
/** Record a pageview → analytics.hanzo.ai. */
export const pageview = (path?: string, properties?: Record<string, unknown>): void =>
getTelemetry().pageview(path, properties)
/** Bind the visitor to a stable person id. */
export const identify = (personId: string, traits?: Record<string, unknown>): void =>
getTelemetry().identify(personId, traits)
/** Associate the visitor with an org/team. */
export const group = (groupId: string, traits?: Record<string, unknown>): void =>
getTelemetry().group(groupId, traits)
/** Report an exception → sentry.hanzo.ai (and `analytics_errors` in insights). */
export const captureError = (err: unknown, context?: TelemetryErrorContext): void =>
getTelemetry().captureError(err, context)
/** Alias of `captureError`. */
export const captureException = captureError
/** Drain the buffer now. */
export const flush = (): void => getTelemetry().flush()
+104
View File
@@ -0,0 +1,104 @@
// Public types for @hanzogui/telemetry.
//
// One vocabulary for the whole thing: a Telemetry is what you call, a
// TelemetryConfig is how you (optionally) shape it. Everything is optional —
// with no config at all the provider resolves a working setup from the
// environment (see env.ts) and the current host.
import type { Analytics, Exception } from '@hanzo/event'
import type { RedactionPolicy } from '@hanzo/observe'
export type { RedactionPolicy }
/** Consent posture.
*
* - `auto` — honor Do-Not-Track / Global-Privacy-Control and any stored
* in-app choice. The default; correct for almost every surface.
* - `granted` — the app has its own consent gate and it said yes.
* - `denied` — the app has its own consent gate and it said no.
*/
export type TelemetryConsent = 'auto' | 'granted' | 'denied'
/** A user's explicit, persisted choice — set by `setConsent`, cleared with
* `'unset'` so the `auto` policy applies again. */
export type StoredConsent = 'granted' | 'denied'
/** Commerce dimensions carried on a revenue event. */
export interface TelemetryCommerce {
productId?: string
quantity?: number
revenue?: number
currency?: string
}
/** Extra context for a reported exception. */
export interface TelemetryErrorContext {
/** false = an unhandled/global error; true = one the app caught and reported. */
handled?: boolean
properties?: Record<string, unknown>
}
/** Everything you may override. Every field has a working default. */
export interface TelemetryConfig {
/** The ONE Hanzo API front door. Defaults to `https://api.hanzo.ai` (or
* `''` for same-origin cookie apps served behind the same edge). There is
* exactly one ingest host — the three lenses are views on the one stream,
* never three endpoints. */
host?: string
/** Emitting surface (`site` | `app` | `chat` | `console` | `cloud` | …).
* Inferred from the hostname when omitted. */
product?: string
/** Publishable ingest key (`pk_…`, write-only, safe in a bundle). Read from
* the environment when omitted — this is what lights up all three lenses on
* a logged-out marketing page. */
ingestKey?: string
/** Bearer provider for token-auth apps. Omit for cookie/session apps. */
getToken?: () => string | undefined | null
/** Hard on/off override. Wins over consent and DNT — use it for a build-time
* kill switch, not for user choice. */
enabled?: boolean
/** Consent posture (default `auto` = honor DNT/GPC + stored choice). */
consent?: TelemetryConsent
/** Capture interactions for session playback (default true). The engine is
* imported lazily, off the critical path, so it never costs LCP. */
replay?: boolean
/** Auto-capture unhandled errors, rejections and React render errors
* (default true). */
errors?: boolean
/** Record pageviews, including SPA route changes (default true). */
pageviews?: boolean
/** Privacy policy for interaction capture. Input values are withheld by
* default; `data-hz-private` excludes a whole subtree. */
redaction?: RedactionPolicy
/** Log what the client is doing to the console. */
debug?: boolean
}
/** The one thing you call. Total and fail-soft: every method swallows its own
* errors, so telemetry can never break the host app. */
export interface Telemetry {
/** Whether anything is actually being sent right now (consent/DNT applied). */
readonly enabled: boolean
/** The resolved emitting surface. */
readonly product: string
/** Record a named product event. */
track(event: string, properties?: Record<string, unknown>, commerce?: TelemetryCommerce): void
/** Record a pageview. The provider does this for you on every route change. */
pageview(path?: string, properties?: Record<string, unknown>): void
/** Bind the visitor to a stable person id (post-login). */
identify(personId: string, traits?: Record<string, unknown>): void
/** Associate the visitor with an org/team. */
group(groupId: string, traits?: Record<string, unknown>): void
/** Report an exception. Unhandled ones are captured automatically. */
captureError(err: unknown, context?: TelemetryErrorContext): void
/** Alias of `captureError`, for muscle memory. */
captureException(err: unknown, context?: TelemetryErrorContext): void
/** Persist cohort dimensions so they ride every subsequent event. */
setCohort(patch: { signupWeek?: string; channel?: string; refCode?: string }): void
/** Drain the buffer now. */
flush(): void
/** The underlying @hanzo/event client — the escape hatch, rarely needed. */
readonly client: Analytics
}
export type { Analytics, Exception }
+72
View File
@@ -0,0 +1,72 @@
// Session capture, off the critical path.
//
// The capture engine (@hanzo/observe) annotates every click/input/submit with a
// semantic hierarchy derived from the DOM, so a replay is readable ("nav /
// Dashboard / UserCard / button[save]") instead of a pixel movie — and it ships
// no CDN script, so a strict `default-src 'none'` CSP cannot break it.
//
// It is imported with a dynamic `import()` inside an idle callback: the engine
// lands in its own chunk, fetched after the page is interactive, so it can never
// cost a millisecond of LCP. If the chunk fails to load, capture silently does
// not happen and the app is untouched.
import { useEffect } from 'react'
import type { RedactionPolicy, Telemetry } from './types.js'
type IdleWindow = Window & {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number
cancelIdleCallback?: (handle: number) => void
}
/** useReplay starts interaction capture once the browser is idle. */
export function useReplay(
telemetry: Telemetry,
active: boolean,
redaction?: RedactionPolicy,
): void {
useEffect(() => {
if (!active || typeof window === 'undefined' || typeof document === 'undefined') return
let cancelled = false
let stop: (() => void) | undefined
const start = (): void => {
if (cancelled) return
import('@hanzo/observe')
.then(({ observe, wireProps }) => {
if (cancelled) return
const engine = observe(
(interaction) => {
// Navigation is owned by useRouteTracking — one source, one count.
if (interaction.kind === 'nav') return
telemetry.track(interaction.name, wireProps(interaction))
},
{ nav: false, redaction },
)
stop = () => engine.stop()
})
.catch(() => {
/* capture is a bonus, never a requirement */
})
}
const w = window as IdleWindow
const idle = typeof w.requestIdleCallback === 'function'
const handle = idle
? w.requestIdleCallback!(start, { timeout: 3000 })
: window.setTimeout(start, 1200)
return () => {
cancelled = true
try {
if (idle) w.cancelIdleCallback?.(handle)
else window.clearTimeout(handle)
} catch {
/* nothing to cancel */
}
stop?.()
}
// `redaction` is read once at start; change it by remounting the provider.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [telemetry, active])
}
+88
View File
@@ -0,0 +1,88 @@
// Pageviews, exactly once each, in exactly one place.
//
// Two shapes of app, one behavior:
//
// • CONTROLLED — the app hands us the current path (Next: `usePathname()`,
// a router: its location). We fire on mount and on every change.
// • UNCONTROLLED — the app hands us nothing. We watch the History API
// ourselves (pushState/replaceState/popstate/hashchange), so a SPA route
// change is counted with zero app code. This is the zero-config path.
//
// Interaction capture is deliberately NOT allowed to also report navigations
// (`nav: false` in useReplay) — two sources would double-count every view.
import { useEffect, useRef } from 'react'
import type { Telemetry } from './types.js'
const location = (): string => {
try {
return window.location.pathname + window.location.search + window.location.hash
} catch {
return ''
}
}
const pathname = (): string | undefined => {
try {
return window.location.pathname
} catch {
return undefined
}
}
/** useRouteTracking records one pageview per navigation. Pass `path` to drive it
* from the app's router, or omit it to let the History API drive it. */
export function useRouteTracking(
telemetry: Telemetry,
active: boolean,
path?: string | null,
): void {
const last = useRef<string | null>(null)
const controlled = path !== undefined
// Controlled: the app's router is the clock.
useEffect(() => {
if (!active || !controlled || typeof window === 'undefined') return
const next = path ?? pathname() ?? ''
if (last.current === next) return
last.current = next
telemetry.pageview(next)
}, [telemetry, active, controlled, path])
// Uncontrolled: the History API is the clock.
useEffect(() => {
if (!active || controlled || typeof window === 'undefined') return
const fire = (): void => {
const key = location()
if (last.current === key) return
last.current = key
telemetry.pageview(pathname())
}
fire() // the initial load counts
const history = window.history
const push = history.pushState
const replace = history.replaceState
history.pushState = function (this: History, ...args: Parameters<History['pushState']>) {
const result = push.apply(this, args)
fire()
return result
}
history.replaceState = function (this: History, ...args: Parameters<History['replaceState']>) {
const result = replace.apply(this, args)
fire()
return result
}
window.addEventListener('popstate', fire)
window.addEventListener('hashchange', fire)
return () => {
history.pushState = push
history.replaceState = replace
window.removeEventListener('popstate', fire)
window.removeEventListener('hashchange', fire)
}
}, [telemetry, active, controlled])
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"types": ["react"]
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.test.tsx"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
})