@hanzo/event
v0.3.41
Published
Hanzo Event — the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes — the error plane needs a DSN, without one nothing reaches Sentry. First-to
Readme
@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/eventinto 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/eventwrites atype:'error'row to the cloud event warehouse (readable viaGET /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(orNEXT_PUBLIC_HANZO_EVENT_DSN). Mint one per property withPOST /v1/sentry/projects. The key is publishable and write-only — safe in a browser bundle, same trust class as apk_ingest key. No DSN means the error plane is inert: nothing is sent, nothing throws, analytics is unaffected. Assertclient.errorPlaneEnabledif you want to know which you have.
No bundler? Use the hosted tag
<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/eventand the tag counts every pageview twice. One surface, one client.
data-keyis a publishablepk-, 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 refused403 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/analyticsand post a bare JSON array of{site, ts, type, path, …}toanalytics.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 whilePOST 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
(
sendBeaconfor cookie/publishable-key apps,fetch(keepalive)for token apps). - Auto error capture (subsumes
@sentry):window.onerror,unhandledrejection, and a ReactErrorBoundaryare reported on both planes — a Sentry envelope to the DSN host, and a correlatedtype:'error'event on the stream. Opt out withcaptureErrors: 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 +
refCodeare parsed once and persisted, then attached to every event. - Cohorts:
signupWeek,channel,refCoderide 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)
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
'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.
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>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 intoproperties.$exceptionand stampsevent_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:
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 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 anonymousIds, so a per-person rate across them would be a lie.
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 restatedGoals & 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.
