mirror of
https://github.com/hanzoai/agency.git
synced 2026-11-02 19:13:28 +00:00
222 lines
9.8 KiB
Plaintext
222 lines
9.8 KiB
Plaintext
# @hanzo/event
|
|
|
|
The ONE telemetry client for Hanzo surfaces. Emits every kind of event —
|
|
`pageview` / `event` / `identify` / `group` **and errors** — to **Hanzo Cloud**,
|
|
never to a third-party.
|
|
|
|
ONE API surface over **TWO** planes. They are separate pipes, and neither can
|
|
starve the other:
|
|
|
|
```
|
|
1. event stream POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
|
|
2. error plane POST {dsn}/v1/sentry/{projectId}/envelope/?sentry_key=… (a real Sentry envelope)
|
|
```
|
|
|
|
> ### Errors need a DSN. Without one, nothing reaches Sentry.
|
|
>
|
|
> **There is no server-side fan-out from `/v1/event` into Sentry.** Versions
|
|
> ≤ 0.3.1 of this README claimed there was — that "Cloud fans the one stream out
|
|
> into three lenses". It does not. `/v1/event` writes a `type:'error'` row to the
|
|
> cloud event warehouse (readable via `GET /v1/errors`) and stops there. Because
|
|
> every Hanzo property believed that claim, nobody set a DSN, and the entire
|
|
> fleet reported **zero** errors to Sentry until 0.3.2 added the envelope.
|
|
>
|
|
> Set `dsn` (or `NEXT_PUBLIC_HANZO_EVENT_DSN`). Mint one per property with
|
|
> `POST /v1/sentry/projects`. The key is publishable and write-only — safe in a
|
|
> browser bundle, same trust class as a `pk_` ingest key. No DSN means the error
|
|
> plane is **inert**: nothing is sent, nothing throws, analytics is unaffected.
|
|
> Assert `client.errorPlaneEnabled` if you want to know which you have.
|
|
|
|
### No bundler? Use the hosted tag
|
|
|
|
```html
|
|
<script defer src="https://api.hanzo.ai/v1/event/tag.js" data-key="pk-…"></script>
|
|
```
|
|
|
|
That one line is the whole install, and it is the same line for a published site,
|
|
hanzo.team and a customer's own page. The tag is served by the endpoint that eats
|
|
the events, so a caller allowlists ONE host and the tag can never drift from the wire
|
|
it posts to — `.js` is part of a segment rather than a child of one, so the tag
|
|
sits UNDER `/v1/event` rather than beside it, and the old sibling `/v1/event.js`
|
|
404s.
|
|
|
|
It autocaptures pageviews — the first one and every SPA navigation, since
|
|
`history.pushState` fires no event of its own — plus uncaught errors and rejected
|
|
promises. Anything a page means on purpose goes through `window.hanzo`
|
|
(`track`, `identify`, `page`, `error`, `flush`). It also carries the site's tag
|
|
config from `/v1/projects/tags`, so a pixel is configured on the project rather
|
|
than pasted into the page.
|
|
|
|
> A bundled app should NOT add this tag. Both clients post pageviews to the same
|
|
> endpoint, so a page running `@hanzo/event` and the tag counts every pageview twice.
|
|
> One surface, one client.
|
|
|
|
> **`data-key` is a publishable `pk-`, and without one the tag is INERT.** A write
|
|
> the edge cannot attribute is refused (`401 ingest_key_required`) silently, so a
|
|
> keyless tag measures fine in the browser and files nothing. A project mints one
|
|
> with itself: `POST /v1/projects`. A key that names no project is refused
|
|
> `403 ingest_key_unknown` — the edge fails closed rather than filing a write it
|
|
> cannot attribute.
|
|
|
|
This package is the client for a surface that **builds**. There is no second
|
|
script-tag distribution here: one wire, one endpoint, one tag.
|
|
|
|
It honours the same consent sources the bundled stack does: an explicit stored
|
|
choice (`hz_consent`, the key a Hanzo consent banner writes) outranks the browser
|
|
signal in both directions; otherwise Global Privacy Control and Do Not Track are
|
|
refusals. A React app does not need this file — mount `<Hanzo analytics>` from
|
|
`@hanzo/ui`, which wires this client and the capture engine together.
|
|
|
|
> The tag used to live in `hanzoai/analytics` and post a **bare JSON array** of
|
|
> `{site, ts, type, path, …}` to `analytics.hanzo.ai/v1/event` — a second protocol
|
|
> behind an identical path spelling, served by a second collector with its own
|
|
> database. `POST api.hanzo.ai/v1/event {"batch":[]}` answered 200 while
|
|
> `POST analytics.hanzo.ai/v1/event []` answered 204, and a client pointed at the
|
|
> wrong host failed silently. Both the second endpoint and the second collector
|
|
> are deleted. One wire, one endpoint, one client home — this package.
|
|
|
|
- **Batched** with a size + interval flush, and **beacon-on-unload**
|
|
(`sendBeacon` for cookie/publishable-key apps, `fetch(keepalive)` for token apps).
|
|
- **Auto error capture** (subsumes `@sentry`): `window.onerror`,
|
|
`unhandledrejection`, and a React `ErrorBoundary` are reported on both planes —
|
|
a Sentry envelope to the DSN host, and a correlated `type:'error'` event on the
|
|
stream. Opt out with `captureErrors: false`.
|
|
- **Scrubbed at the source**: secrets are always redacted and PII is masked
|
|
client-side before an error leaves the device.
|
|
- **First-touch attribution**: UTM + referrer + `refCode` are parsed once and
|
|
persisted, then attached to every event.
|
|
- **Cohorts**: `signupWeek`, `channel`, `refCode` ride each event.
|
|
- **Tenant-safe**: the client NEVER sends an org/tenant — Cloud stamps it from
|
|
the validated session or the signed publishable key.
|
|
- **Fail-soft**: telemetry loss is swallowed; the client never throws into the app.
|
|
- **SSR-safe**: importing on the server is a no-op; it only acts in the browser.
|
|
|
|
## Core (framework-agnostic)
|
|
|
|
```ts
|
|
import { createAnalytics, EVENTS } from '@hanzo/event'
|
|
|
|
// Cookie/session apps (console, admin): same-origin, no token.
|
|
const analytics = createAnalytics({ product: 'console' })
|
|
|
|
// Token apps (app, site): give the cloud host + a bearer getter.
|
|
const analytics = createAnalytics({
|
|
product: 'app',
|
|
host: 'https://api.hanzo.ai',
|
|
getToken: () => localStorage.getItem('hanzo_access_token') ?? undefined,
|
|
})
|
|
|
|
analytics.pageview()
|
|
analytics.identify('user-42')
|
|
analytics.capture(EVENTS.SIGNUP_COMPLETED, { plan: 'pro' })
|
|
analytics.capture(EVENTS.ORDER_COMPLETED, { kind: 'plan' }, { productId: 'plan_pro', revenue: 49, quantity: 1, currency: 'usd' })
|
|
```
|
|
|
|
## React
|
|
|
|
```tsx
|
|
'use client'
|
|
import { AnalyticsProvider, useAnalytics, usePageview } from '@hanzo/event/react'
|
|
import { usePathname } from 'next/navigation'
|
|
|
|
export function Providers({ children }) {
|
|
return <AnalyticsProvider config={{ product: 'console' }}>{children}</AnalyticsProvider>
|
|
}
|
|
|
|
function RouteTracker() {
|
|
usePageview(usePathname()) // one pageview per navigation
|
|
return null
|
|
}
|
|
|
|
function UpgradeButton() {
|
|
const a = useAnalytics()
|
|
return <button onClick={() => a.capture(EVENTS.PLAN_CLICKED, { plan: 'pro' })}>Upgrade</button>
|
|
}
|
|
```
|
|
|
|
## Errors (the @sentry replacement)
|
|
|
|
Unhandled errors and promise rejections are captured automatically. React render
|
|
errors never reach `window.onerror`, so wrap your tree in the `ErrorBoundary` to
|
|
catch those too. Report caught errors yourself with `captureError`.
|
|
|
|
**Pass a `dsn` or none of this reaches Sentry.**
|
|
|
|
```tsx
|
|
import { ErrorBoundary } from '@hanzo/event/react'
|
|
|
|
<AnalyticsProvider config={{ product: 'console', dsn: process.env.NEXT_PUBLIC_HANZO_EVENT_DSN }}>
|
|
<ErrorBoundary fallback={(err, reset) => <Crash error={err} onReset={reset} />}>
|
|
<App />
|
|
</ErrorBoundary>
|
|
</AnalyticsProvider>
|
|
```
|
|
|
|
```ts
|
|
try { risky() } catch (err) { analytics.captureError(err, { properties: { where: 'checkout' } }) }
|
|
```
|
|
|
|
Each report goes to both planes:
|
|
|
|
- **Sentry** — a real Sentry envelope to the DSN's ingest route. This is the only
|
|
thing that creates an issue in the error dashboard, with grouping and stack
|
|
frames. Sent one envelope per error, immediately; batching a crash report is
|
|
how you lose it.
|
|
- **the event stream** — a `type:'error'` event; Cloud folds the exception into
|
|
`properties.$exception` and stamps `event_type='error'`, so the error stays
|
|
correlated with the session's pageviews (`GET /v1/errors`). This is product
|
|
signal, *not* error tracking, and it never reaches Sentry on its own.
|
|
|
|
The message and any `properties` are scrubbed of secrets and PII before sending,
|
|
and the message is capped at 8KB. `captureError` never throws back into your app,
|
|
and a failure on one plane cannot suppress the other.
|
|
|
|
## Publishable key (public pages, no bearer)
|
|
|
|
Marketing/public pages have no session. Mint a write-only publishable key
|
|
(`POST /v1/ingest/keys`) and pass it as `ingestKey`; it rides `Authorization` on
|
|
fetch and `?ingest_key` on an unload beacon, so the **event stream** accepts
|
|
anonymous traffic. It is safe to ship in a bundle (write-only, cannot read).
|
|
|
|
The `ingestKey` authenticates the event stream ONLY. The error plane
|
|
authenticates independently with the DSN key on `?sentry_key=`, and the two
|
|
credentials are never sent to each other's host. A public page that wants errors
|
|
in Sentry needs the `dsn` as well:
|
|
|
|
```ts
|
|
createAnalytics({
|
|
product: 'site',
|
|
host: 'https://api.hanzo.ai',
|
|
ingestKey: 'pk_live_…', // event stream
|
|
dsn: process.env.NEXT_PUBLIC_HANZO_EVENT_DSN, // error plane
|
|
})
|
|
```
|
|
|
|
## Taxonomy, funnels & goals
|
|
|
|
**[TAXONOMY.md](./TAXONOMY.md) is the canonical spec** — naming convention,
|
|
property rules, `identify`/`group` semantics, the funnels for hanzo.ai /
|
|
hanzo.app / hanzo.chat, and the exact emit site (file:line) of every event on
|
|
each surface. Read it before adding an event.
|
|
|
|
`FUNNELS` (see `funnels.ts`) is the one funnel registry: each journey is an
|
|
ordered list of steps naming `EVENTS` values, scoped by `product`. A funnel that
|
|
spans origins while logged out is marked `join: 'aggregate'` — two origins mean
|
|
two `anonymousId`s, so a per-person rate across them would be a lie.
|
|
|
|
```ts
|
|
import { FUNNELS, GOALS } from '@hanzo/event'
|
|
|
|
FUNNELS.appShip.steps.map((s) => s.event)
|
|
// ['$pageview','build_started','generation_completed','deploy_started','deploy_succeeded']
|
|
GOALS.signup.funnel // derived from FUNNELS.signup — never restated
|
|
```
|
|
|
|
## Goals & cohorts
|
|
|
|
`GOALS` and `COHORTS` (see `goals.ts`) are the shared, machine-readable insights
|
|
spec: **Signup** (funnel view→submit→verify→first-action), **Sale** (a
|
|
`order_completed` with `kind=plan`), and **Upgrade Intent** (`plan_clicked`,
|
|
funnel from `pricing_viewed`). Cohort fields map to the `signup_week`,
|
|
`channel`, and `ref_code` columns of `hanzo.events`.
|