/login?from=logout left for hanzo.ai like any signed-out visit, and hanzo.ai answers a *.hanzo.ai callback from its own session on arrival, so signing out signed the person straight back in. That page now says they are signed out and offers Sign in.
8.2 KiB
Hanzo Analytics
Overview
Privacy-focused web analytics for the Hanzo ecosystem. Multi-tenant IAM integration.
Upstream: Umami (MIT). Branded as Hanzo Analytics.
Tech Stack
- Language: TypeScript (Next.js)
- Database: PostgreSQL (Prisma ORM), Hanzo Datastore (
hanzoai/datastore) - Auth: Hanzo IAM (OIDC + PKCE); people sign in on hanzo.ai
Build & Run
pnpm install && pnpm build
pnpm build-db-client && pnpm test
pnpm test is jest through jest.transform.cjs (esbuild): ts-jest needs
TypeScript's JavaScript compiler API, which TypeScript 7 does not expose, so every
suite failed before it ran. The transform also hoists jest.mock, shims
import.meta.url for the generated prisma client, and carries ESM-only uuid.
Release
hanzo.yml + .github/workflows/cicd.yml (the hanzoai/ci reusable, mode: delegate): a push to main runs the unit gate and has the door build
ghcr.io/hanzoai/analytics:<package.json version> in-cluster, both architectures.
Build only: the pin in hanzoai/universe charts/app/values/hanzo/analytics.yaml
is moved by hand, and Hanzo CD (hanzo-analytics, manual sync) reconciles it.
Before 2026-10-16 the only workflow was a buildx+qemu deploy.yml on a repo
with GitHub Actions disabled, so nothing built; v3.1.22-v3.1.26 were built and
pinned by hand. Bump version in package.json to name a release.
Sign-in
Hanzo signs in on hanzo.ai, and nothing here calls or links hanzo.id.
src/lib/iam.ts is the whole of it. A signed-out visit to any page (App.tsx)
stashes the path and leaves for https://hanzo.ai/login carrying THIS app's own
authorize request, built by @hanzo/iam (sdk.getSigninUrl()): client
hanzo-analytics, callback <origin>/auth/callback (registered on the IAM app),
a fresh state and S256 challenge whose verifier stays in this origin's storage.
/login and /signup draw nothing and leave the same way (/signup goes to
hanzo.ai/signup). hanzo.ai's page finishes the request (@hanzo/iam
asked/ask): "Continue as " for a person signed in there, its own
sign-in otherwise. IAM mints the code for this client and callback;
/auth/callback exchanges it and returns to the stashed path (takeReturnTo,
same-origin paths only, never a sign-in page).
The browser asks IAM at https://api.hanzo.ai and nowhere else: discovery
(serverUrl), and token, refresh, userinfo and revocation (proxyBaseUrl; this
host does not route /v1/iam, and api.hanzo.ai answers CORS for it). The
server verifies bearers against api.hanzo.ai/v1/iam/.well-known/jwks with the
issuer pinned to https://hanzo.id, the iss tokens name, which is never
fetched. /logout revokes both tokens at api.hanzo.ai and lands on
/login?from=logout, which draws "You are signed out" and a Sign in button and
waits: hanzo.ai answers a request whose callback is a *.hanzo.ai host from its own
session on arrival, so leaving at once would sign the person straight back in.
src/lib/__tests__/signin.test.ts holds the address, the config, the return
path and the absence of hanzo.id from the source.
Multi-Tenant Architecture
- IAM OIDC login extracts
ownerclaim (org slug) from JWT src/lib/iam-org.tsmaps org slug -> deterministic Team UUID (v5)- Each org gets an auto-created Team; users are auto-assigned on login
- Websites scoped to Teams provide per-org data isolation
- White-label branding via env vars:
NEXT_PUBLIC_APP_NAME,NEXT_PUBLIC_IAM_PROVIDER_NAME
Visits arrive from cloud's event bus
Every site posts its events to api.hanzo.ai/v1/event, the one ingest, and
nothing posts to this app as a destination. src/lib/bus.ts binds the durable
analytics on cloud's EVENT stream, filtered to event.act
(CLOUD_PUBSUB_URL, nats on cloud.hanzo.svc:4222), from src/instrumentation.ts
at server start. On its first bind it reads what the stream still holds (three
days), which is the backfill.
src/lib/visit.ts is the whole mapping, pure and tested: a page fact is a
pageview and a track fact with a plain name is a custom event, on the website
whose domain the fact's host is or ends with (so *.hanzo.app is hanzo.app).
The visitor is anonymous_id, else distinct_id, and the session is
uuid(website, visitor). The fact id names the stored event, so a redelivery is
refused as a duplicate. Browser, os, device, country, screen and language are
read from the fact's attributes, stamped once at ingest; the agent is parsed
only when ingest did not name the device. The fact vocabulary is HIP-1190 §6.1.
The Hanzo websites belong to team 5340db05… (uuid v5 of the IAM org hanzo).
/websites lists a person's own websites and every website of a team they are
in; an admin-org member joins team hanzo on first sign-in.
/v1/send (the upstream tracker door) remains only while the last pages that
still load script.js move to @hanzo/event; delete the route then.
Every route is /v1/*; /v1/event is not one of them
The app's routes live in src/app/v1/** and nothing is served under /api/.
The dashboard calls them through useApi (/v1${url}), the tracker posts to
/v1/send (and /v1/ast, /v1/element, /v1/section), and the recorder rides
the same /v1/send. src/lib/__tests__/v1-surface.test.ts fails if
src/app/api comes back or any first-party file names an /api/ path again.
The measurement product's ingest door is hanzoai/cloud's: apps/analytics
owns /v1/analytics plus /v1/event and /v1/insights/e on api.hanzo.ai. This
repo holds per-site history in its own Postgres and is not a second
implementation of that surface — never add src/app/v1/event or
src/app/v1/analytics here.
POST /v1/event (src/app/v1/event/route.ts) — DELETED. It took a bare JSON
array of {site, ts, type, path, …} envelopes on a path spelled identically to
cloud's front door, which takes {batch:[…]}. One path spelling, two protocols,
two servers — so a @hanzo/event client pointed at the wrong host failed
silently. One door survives and it is cloud's.
public/hz.js — MOVED to hanzoai/ui → pkgs/event (@hanzo/[email protected]).
The tag is a client, and the house has one client home. There it is the
script-tag distribution of @hanzo/event: same WireEvent batch, same
POST {host}/v1/event, default host api.hanzo.ai, shipped on the CDN as
https://unpkg.com/@hanzo/event/hz.js. Do not add a tracker back here.
collector/ (Go) — DELETED. A THIRD event collector, its own Go module,
deployed nowhere in universe, forwarding to a service that no longer exists.
src/lib/insights-forward.ts — DELETED, with its call site in
src/app/v1/send/route.ts and its INSIGHTS_HOST / INSIGHTS_API_KEY env. It
fire-and-forgot every event at that same deleted service.
The client SDK is hanzoai/ui → pkgs/event, published as @hanzo/event. This
repo used to carry a FORK of it at packages/event (@hanzo/[email protected], never
published). That fork is deleted — do not recreate it. It was not harmless: it held
the ONLY working Sentry-envelope implementation while the published package shipped
a comment claiming POST /v1/event was "lensed server-side" into Sentry. It is not,
so every Hanzo property reported zero errors for as long as both copies existed.
The envelope + scrub code merged into the canonical package in @hanzo/[email protected].
Key Integration Points
- IAM auth:
src/lib/iam.ts,src/app/auth/callback(redirect_uri/auth/callback) +src/app/v1/auth/verify/route.ts - Branding:
src/lib/branding.ts-- runtime env-based white-label config - Commerce billing:
src/lib/commerce.ts+src/app/v1/cron/billing/route.ts-- usage metering to Commerce/v1/billing/usage(inert: COMMERCE_API_URL is unset in prod, and the payload is not the shape commerce's RecordUsage binds)
K8s Environment Variables (deployment.yaml)
DATABASE_URL,APP_SECRET,KV_URL-- from KMS viaanalytics-secretsNEXT_PUBLIC_IAM_CLIENT_ID,NEXT_PUBLIC_IAM_ORG-- the IAM client (build-time); where IAM is asked issrc/lib/iam.ts, not envCOMMERCE_API_URL,COMMERCE_TOKEN-- billing meteringDATASTORE_URL-- Hanzo Datastore connection (optional)ALLOWED_ORIGINS-- CORS whitelist for tracker scripts