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:
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user