Event
Package analytics is product analytics: send an event, read back who did what.
Package analytics is product analytics: send an event, read back who did what.
| Base URL | https://api.hanzo.ai |
| Operations | 12 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1190 · Event — The Product Analytics Plane — Draft · read the specification →
/v1/event is product analytics: send an event, read back who did what. It is
both halves on purpose — one write core behind every ingest door, and the read
lenses over the same warehouse — so a fact is admitted, stamped with the
server-resolved tenant, and read back through one vocabulary. It is implemented
in hanzoai/cloud at apps/event.
Motivation
A product event is a claim about somebody's customers, arriving from a browser that nobody controls. Two things decide whether the claim is worth anything: who it gets filed under, and whether the caller is told the truth about what landed. Both are properties of the door, not of the warehouse behind it. Putting the doors and the lenses in one capability is what makes the answer to "what did we store" and the answer to "what can you read" the same sentence.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The store
It owns none, and the discipline that follows from that is the point. The event
plane's schema belongs to hanzoai/o11y and this capability MUST NOT create or
migrate it: it is a writer and a reader there, no more
(apps/event/capture.go:88-97). A lens over a table it does not own answers
honest-empty when that table is absent rather than erroring
(apps/event/mount.go:524-531). The one table it touches with a
statement of shape is the model surface's usage ledger, ensured idempotently so
that a fresh warehouse yields honest zeros instead of an error. It holds no
connection either — one datastore client serves the whole binary, opened from
KMS-injected credentials on first use.
What it DOES own is the grammar of the plane: the subject a fact travels on, the
durable a consumer binds, and the envelope both vocabularies are read out of.
Those are one decision, and a consumer that also published would be a second
owner of all three (apps/event/bus.go:478-487).
§2 The addresses
Typed. GET /v1/event/overview (the caller org's KPIs for one window),
GET /v1/event/timeseries (usage over time as an evenly-spaced series),
GET /v1/event/top (five ranked lenses at once), GET /v1/event/errors (the
org's most recently captured errors, newest first),
GET /v1/event/insights/events (its most recent product events, newest first),
GET /v1/event/insights/health, and GET /v1/event/health — which declares
BOTH statuses it answers with, so a degraded probe carries the degraded report
as its body instead of an empty error.
These read under /v1/event because a lens over the plane this capability
writes is not a second capability: it owns no store of its own to be one with
(§1). Five stems — /v1/analytics, /v1/errors, /v1/insights, /v1/replay
and /v1/event.js — folded here (HIP-0139 §7.1), and two of those folds cost
real callers rather than being aliased, because §7 has no fourth way. The tag is
embedded as a script src in customer HTML, so a page nobody re-embeds stops
reporting; the replay path is compiled into deployed bundles, so they ship no
replays until they rebuild. Both are stated because a spec that omits the cost
of its own fold is a spec somebody pays for by surprise.
Declared with prose, and why each cannot be a value.
POST /v1/event is the one door for every wire a surface emits, dispatched by
the SHAPE of the body and never by a second path: a bare event object, a bare
array of them, the {batch:[…]} and {events:[…]} envelopes, and the wire
spelled distinct_id/api_key. Batch is a body, not a path — there is no
/v1/event/batch, because an array already is one. It cannot be a typed
operation for two independent reasons. A bare JSON array decodes into no struct,
and a typed operation refuses a body it cannot unmarshal before the handler
runs. And admission is decided from facts that live only on the raw request:
which of three carriers presented the credential, the client address and the
socket peer the caps key on, the do-not-track and global-privacy-control
headers, and the body whose LENGTH is the reduced lane's 64 KiB bound and whose
first non-space byte selects the wire.
GET /v1/event/tag.js answers JavaScript. The hosted tag is the whole install for a
surface with no bundler, and it is served here, beside the door, because a tag
that drifts from its wire is a tag that 400s: the two ship in one binary and
version together. A script is not a value.
POST /v1/event/{project}/envelope and POST /v1/event/{project}/store carry
the error-SDK wires, whose spelling those SDKs own. The DSN key authenticates
itself, so there is no Hanzo principal on this path by design; the whole request
travels — path, query, headers and body — and the answer is relayed VERBATIM,
because a 401 is the SDK's signal to stop retrying and must not be reshaped into
this API's error envelope.
POST /v1/event/replay takes a session-recorder snapshot batch. It is opaque, bound
for a different consumer on a different transport, and lands no warehouse row at
all — so it is not one of the doors and cannot be, since every door is a wire
that decodes to events and flows through the one write core. What it shares is
the half that matters: admission, resolved by the same resolver and refused in
the same words. A produce failure is a 503, never a 200: the produce is the
commit point, and a fire-and-forget would turn the receipt into a maybe.
The receipt is the same everywhere. Every door and every lane answers
{accepted, dropped}, and the two always total what was sent — a beacon is
never silently discarded. The status says whether anything landed, so a green
answer can never mean an empty warehouse: 200 means at least one event was
stored, or that nothing was sent; a nonzero dropped beside a nonzero
accepted is a PARTIAL batch, never a failed one, because a batch is not
refused whole for its worst element. If nothing was stored the answer MUST be an
error naming the one thing that fixes it.
§3 The boundary
Three capabilities touch telemetry, and a reader will confuse them. The split is by whose data it is and who owns the schema.
metrics is the rail for a deployment's own logs, metrics and traces at
/v1/logs, /v1/metrics and /v1/traces: append a record, read the series
back over a range. Ingest and query, no product vocabulary.
o11y (HIP-1240) is the observability plane: the query engine, the dashboards
and alerts, the error-tracking face, and — the fact that decides this boundary —
the OWNER of the event.* schema. Anything that creates or migrates a table on
that plane is o11y's, and this capability MUST NOT issue DDL against it.
event is the product event: what a person or a surface DID, admitted at the
beacon door under a publishable key, and the per-org lenses read back over it.
The beacon door is this capability's name as well as its address — /v1/event
is what @hanzo/event, the hosted tag and HIP-0132's telemetry ingest all
hard-code — and it MUST NOT be re-served under another: a minted DSN keeps addressing /v1/event unchanged, and the
error wires it carries are forwarded to o11y over the internal plane rather than
reimplemented here. In the other direction, an error a customer sends to
/v1/event reaches the same surface a native SDK reaches, because the accepted
batch is handed to o11y's own sink.
One more line, because money is nearby: usage (HIP-1313) is the metered record a customer is billed on. The LLM lens READS that ledger and asserts nothing about it. A lens is not a source of billing truth.
§4 The tenant
Two kinds of caller, one rule: the org is server-resolved, never a field on the wire and never a client header.
Reads resolve principal.Acting — the org minted from the validated
bearer's owner claim (HIP-0026) — and refuse 403 without it, which also closes
the direct-to-pod path where an identity header is restored but no user is.
Every warehouse query binds the org POSITIONALLY, so even a token with wide
scope cannot read another org's rows.
Writes go through one resolver in a strict trust order
(apps/event/event.go:167): a validated bearer or an org API key; then a
publishable pk- key on Authorization, on x-hanzo-ingest-key, or in an
ingest_key query parameter for navigator.sendBeacon, which cannot set
headers; then, last, a signed workspace session, which is the only credential
that can resolve at REDUCED capability because it is the only one issued to a
principal weaker than "holds an API key". Last because it is narrowest: a
request holding both is attributed to the deliberate API credential and never to
whatever tab it came from.
A pk- resolves WHICH tenant a beacon belongs to and nothing more. It never
authenticates and can read nothing — not the org's errors, not a lens, not any
other route on this API. So a leaked one lets a stranger write into a stream and
never lets one read out of it, and reading rows back always takes a real bearer.
No credential is refused into a shared tenant. There is no reserved
anonymous tenant (apps/event/public.go:115). A write the server cannot
attribute to a project is 401 ingest_key_required, and a credential that is
presented but resolves to no project is 403 ingest_key_unknown. Events nobody
can read are worse than events nobody sent, because the caller was told it
succeeded.
The reduced lane writes through a projection, not a filter. It is narrowed
to what the SERVER can name, and each name resolves through a server-owned table
and is stored as that table's value, so the name on the wire is never the name
in the row — one spelling and its variants are one name, not two. It is a fresh
value built from the fields the projection names, so a field it does not name
cannot reach a row at all, and an exception is carried on the error kind and
nowhere else, so an interaction cannot ship a stack trace into its attributes.
It does not name the person: the signed account is the identity, so a
distinctId in the body cannot pin events on a colleague. The browser ids it
does supply are stored under a reserved prefix no identified subject can carry,
so an unattested row can never join, in any lens, to a person the org actually
knows. Everything refused is counted in dropped.
The tenant is stamped where the row is BUILT, once, so a caller can only ever write into its own partition.
§5 Money
Free, and said in those words: the surface declares cloud.Free
(plugin/event/main.go). Neither ingest nor a read debits any plane.
§6 The events it publishes
It publishes, and what it publishes is deliberately not named in our vocabulary.
An accepted batch is committed as FACTS under event.<signal>, where signal is
a closed set naming the sort of occurrence. That is the internal hand-off the
warehouse drains. Separately, one ENVELOPE per event is published under
event.<folded event name> — the grammar an org subscribes to through
/v1/webhooks, so signup_completed reaches a customer as
event.signup_completed. A name is folded to a subject token and bounded, so a
hostile name cannot mint unbounded subject cardinality; the fold is a PUBLISHED
contract and MAY gain cases but MUST NOT change an existing mapping.
The name is the customer's, which is why this capability publishes no
<name>.<noun>.<verb> event of its own — the event.* subjects above carry the
tenant's names, not ours. Restating a tenant's own event under our vocabulary
would assert a name over theirs and deliver every subscriber one subject instead
of the one they asked for.
Two vocabularies therefore share one stream, and the discriminator is the BODY, not the subject: a fact names its signal and an envelope does not. Each consumer MUST take the vocabulary it speaks and leave the other alone — the warehouse lands facts and ignores envelopes, the webhook delivery does the reverse — and a payload that names no signal is not a lost fact.
Two orderings are normative. The fact publish is the COMMIT POINT and is
synchronous: the door answers accepted only once the broker holds the message.
The drain then commits before it acknowledges, in that order always, because
acking first loses the fact while the bus believes it delivered; redelivery is
safe because the fact table collapses a redelivered row on merge, which makes
idempotency structural rather than something each consumer remembers. The
envelope publish is fail-soft and detached, because the durable copy is already
committed by the time it runs — a bus that is down for the second vocabulary
costs deliveries, never data.
§7 Observability
Beyond the request span every route gets, one counter and one log line, on
purpose. hanzo_ingest_dropped_total counts events a door received and did not
land, labelled by tenant, door origin and reason. It is per REASON rather than
one total, because "a fleet of clients writing with no usable credential" and
"one client sending bodies nothing can store" are different incidents and an
alert that cannot tell them apart wakes the wrong person. The log line names the
same three, so whoever the alert wakes knows which tenant and which door.
Cardinality is bounded on all three labels: the tenant is server-resolved, the
origin comes from the finite set of doors, and the reason is two values.
§8 Stage
ga. The manifest row declares no stage, and absent is ga (HIP-0139 §8).
§9 Upstream
It derives from none: it forks, embeds and mirrors no OSS project. It ACCEPTS
foreign wires at its own doors as interoperation — the product-analytics wire
spelled distinct_id/api_key, and the error-SDK envelope and store wires,
relayed unchanged — which is a fact about what an unmodified client may send,
not a dependency. The hosted tag carries one file copied verbatim from
@hanzo/event, the anonymous-identity chain, because a tag with no bundler
cannot import anything: one implementation, two distributions, so a browser is
one person on every Hanzo surface whichever client a page loaded.
Rationale
The alternative to one door is a path per wire. Five doors is five admissions,
five receipts and five places for the meaning of accepted to drift, and a
caller learns which one it hit by reading a changelog. Shape dispatch costs one
byte of lookahead and keeps the contract single.
The alternative to a server-stamped tenant is a tenant field on the wire, which works until one client sets it — and by then the rows are already filed and the lens is already wrong. The alternative to refusing an unattributable write is filing it somewhere shared, which returns 200 to a caller whose data nobody will ever read.
Security Considerations
An ingest door is a write into somebody else's dataset, so the wrong implementation hands an attacker three distinct prizes.
If the tenant came off the wire, any caller could file rows into any org's warehouse, and that org's dashboards would report a stranger's traffic as their own — a corruption that reads as data, not as an attack. The tenant is resolved from the presented credential and stamped where the row is built.
If an unattributable write were filed under a shared tenant, the caller is told it succeeded and the owner can never read it. A silence that looks like success is worse than a refusal, which is why the only two answers are the org a credential named, or an error.
If a publishable key authenticated as well as attributed, every browser bundle on the internet would be carrying a read credential for its org's errors and lenses. It does not: a read never accepts a write-only key.
The reduced lane is where a signed-in person's tab meets an open vocabulary, so it is bounded on both the request and the value: refused over 64 KiB, refused over fifty events, capped independently per client address and per socket peer — two buckets, because the address is a header a direct caller can rotate and the socket peer at the edge is shared by all public traffic — and a do-not-track or global-privacy-control request stores nothing and says so in the receipt. Both bounds REFUSE rather than truncate, since a silent truncation would make the receipt a lie. Two stored values carry their own bounds on top, because bounding a request does not bound one value inside it.
The consumer seam has a matching rule. A consumer that FORWARDS sees the batch before the warehouse scrub, because a server-side forwarder must hash the match keys the warehouse deliberately drops, and it hashes them before they leave the process. A consumer that STORES sees the scrubbed copy, because a projection that stored more than the plane stores would be a second copy of the batch under a weaker rule. Every sink runs detached and fail-soft, so a slow or broken consumer can never block, fail or crash an ingest.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | event at its own prefix | 12 operations |
| CLI | hanzo event … | 3 of 12 — the CLI pins the document on its own clock |
| SDK | EventApi in every published client | 3 of 12 — the clients are generated at their own release |
| MCP | — | no tool names it yet — use HTTP or an SDK |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/event/top, operation get_event_top:
hanzo has no subcommand for this operation — the CLI serves only what cloud's live route table confirms. Use HTTP or an SDK.
import { Configuration, EventApi } from 'hanzoai';
const api = new EventApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getEventTop();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import EventApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = EventApi(client).get_event_top()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.EventAPI.GetEventTop(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, event_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = event_api::get_event_top(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.EventApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new EventApi(client).getEventTop();The method above is the one at the current release of the document. [email protected] (npm) and [email protected] (PyPI) were generated from an earlier release, where this operation carried a different id, so it spells the method differently — regenerating the clients is what makes the two agree. SDKs →
curl https://api.hanzo.ai/v1/event/top \
-H "Authorization: Bearer $HANZO_API_KEY"The door declares no tool for event — tools/list on https://api.hanzo.ai/v1/mcp names the products it does reach. Use HTTP or an SDK.
Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/event/{project}/envelope | Sentry SDK envelope ingest — errors and traces from an unmodified Sentry client |
POST /v1/event/{project}/store | Sentry SDK store ingest — the legacy single-event wire |
GET /v1/event/errors | Errors returns the caller org's most recently captured errors, newest first. |
GET /v1/event/health | Health reports whether the event plane can take a write and the warehouse can answer a read. |
GET /v1/event/insights/events | Returns the caller org's most recent product events, newest first. |
GET /v1/event/insights/health | Reports that the unified insights surface is serving. |
GET /v1/event/overview | Overview returns the caller org's analytics KPIs for one time window. |
POST /v1/event/replay | Record a session-replay snapshot batch |
GET /v1/event/tag.js | The Hanzo event tag — the one-line install for a surface with no bundler |
GET /v1/event/timeseries | Timeseries returns the caller org's LLM usage over time as an evenly-spaced series. |
GET /v1/event/top | Top returns the caller org's ranked lenses for one window, five of them at once. |
POST /v1/event | Capture product events into your org's warehouse |
How is this guide?