Documentation
¶
Overview ¶
Package analytics mounts the Hanzo Cloud /v1/analytics/* surface: a native-Go, per-org analytics read API over the `hanzo` datastore warehouse (the `datastore` cluster). It is the backend for the console Native Analytics module (unified-analytics.md §5) — two read lenses over one warehouse:
- LLM lens (REAL today): hanzo.cloud_usage, the live per-org usage ledger the cloud o11y path already writes (requests, tokens, spend, models, errors).
- Web/commerce lens (honest-empty until the collector emits): hanzo.events.
ONE datastore client. This package does NOT open its own connection: it reads through clients/datastore, the leaf that holds the warehouse connection for the whole binary and opens it from the environment on first use. DRY: one transport, one pool, one set of KMS-injected DATASTORE_* creds — never hard-coded, never a second design. (It used to reach the connection through ai/object's Bootstrap; that path cost 1,933 packages to call four functions, which is why the connection moved to a leaf importing only orm/datastore.)
TENANT ISOLATION is the security bar and is enforced SERVER-SIDE on every request. The org is c.Org() — the value SanitizeIdentity minted from the VALIDATED bearer owner claim (HIP-0026), never a client header — AND every request must carry a validated principal (c.User() set, which SanitizeIdentity sets ONLY for a verified bearer). This closes the Phase-1 "no-bearer + forged X-Org-Id direct-to-pod" cross-tenant read exactly as clients/s3 does. Every datastore query binds the org POSITIONALLY (query.go llmWhere/eventsWhere), so a maxpower token can NEVER read another org's analytics.
Surface (all org-scoped; /v1 only; read-only):
GET /v1/analytics/overview per-org KPIs (llm real; web/commerce honest-empty)
GET /v1/analytics/timeseries requests/tokens/spend over time (hour|day buckets)
GET /v1/analytics/top top models (real) + products + behavior lenses
(topPages/topReferrers/topSources over the events lens)
GET /v1/analytics/health subsystem health (datastore connectivity + lens tables)
Registered as id "analytics" with cloud.HealthOwner + order 132: it serves its OWN /v1/analytics/health (below), and cloud.HealthOwner makes serve.go skip the generic GET /v1/<name>/health so the always-ok route never shadows the real probe — the same flag the kms/paas/s3 subsystems use. Order 132 binds /v1/analytics/* before the ai subsystem's /v1/* catch-all (150).
campaign.go is the in-process CAMPAIGN-METRICS seam over the ONE analytics warehouse: the /v1/campaign plane (clients/campaign) reads a campaign's funnel from HERE rather than opening a second store. A campaign's results ARE an analytics query scoped to the campaign — the utm_campaign-tagged events in hanzo.events — so there is one metrics plane, not a parallel one.
TENANCY: identical to every other query this package builds. campaignWhere binds the org (tenant_id) AND the campaign id (utm_campaign) AND the optional variant (utm_content) POSITIONALLY — nothing user-derived is ever interpolated, so a caller can only ever read its OWN org's campaign, and the utm_campaign filter can never escape into SQL. The variant arg powers the creative-A/B evidence read (utm_content) the experiment primitive composes.
Capture (WRITE) side of the analytics plane. analytics.go serves the read lenses over hanzo.events; this file is the symmetric ingest that FILLS that table, so the web/commerce lenses stop being honest-empty. Products emit here (the ONE native front door) instead of talking to the insights capture service directly — cloud owns the tenant boundary and the warehouse schema.
Routes (all POST; org resolved SERVER-SIDE from the validated principal):
POST /v1/analytics capture one batch of events -> {accepted,dropped}
POST /v1/analytics/batch alias of the above (Segment-style)
POST /v1/tracker beacon alias — navigator.sendBeacon / fetch(keepalive)
on page-unload posts here; SAME handler, SAME tenant
gate. It is a bare route: the /v1/tracker/* issue
tracker (clients/tracker) owns only /v1/tracker/projects*,
so bare POST /v1/tracker never collides with it.
TENANCY: the row's tenant_id is ALWAYS principal.Org (the validated IAM owner slug), never a client-supplied field — a caller can only ever write into its OWN org's partition, the same isolation invariant the read side enforces. The client controls distinct_id/session_id/properties (its own visitors), never the tenant.
PRIVACY: normalizeEvent scrubs credential- and PII-shaped property keys and any email-shaped value before the row is built (scrubProps). Only user/org identifiers (distinct_id, person_id, group_id, org) are retained as identity.
ONE datastore client: writes ride ai/object.DatastoreExec — the SAME pooled, KMS-credentialed connection the read side queries through — so there is no second transport, pool, or credential path.
event.go — the ONE canonical event-ingestion front door.
POST /v1/event body: Event | [Event] | {batch:[…]} -> {accepted, dropped}
ONE door, EVERY wire, EVERY auth context. The decoder (decodeIngest) is wire-tolerant: a bare canonical Event object, a bare Event array, AND the CaptureBatch envelope ({batch:[…]} | {events:[…]}) the Segment/beacon/publishable paths speak all decode onto the SAME []CaptureEvent the ONE write core (ingestEvents) consumes, into the SAME hanzo.events table. There is deliberately no /v1/event/batch — a JSON array, or a batch envelope, IS the batch.
AUTH is the orthogonal, PLUGGABLE concern on this one door (eventTenant), resolved SERVER-SIDE and FAIL-CLOSED, in strict trust order:
- a validated IAM bearer principal — its owner org;
- a publishable key (pk-…) — IAM resolves it to its org; it can write but SAME key publishable.go mints; folded in here so a pk_ caller uses /v1/event directly);
- an out-of-band IAM access key (hk-/sk-…) — resolved through the ONE key seam (cloud.OrgForKey).
A caller that PRESENTED one of those credentials and did not resolve ⇒ 403 (a misconfigured key is refused, not downgraded). A caller that presented NOTHING falls through to the ANONYMOUS lane (public.go): logged-out marketing traffic is admitted and attributed to the reserved public tenant, under a restricted kind/field allowlist and its own size + rate bounds. It rejoins this pipeline at ingestDecoded, so decode, write core, and receipt are shared — only admission differs.
There is NO brand-host fallback on the canonical door (that path stays only on the deprecated aliases), so /v1/event never writes an event into a REAL tenant IAM did not vouch for. The org is NEVER read from the body — on either lane.
The site-host carve (eventWithOrg) is the ONE exception to in-handler auth: on a published site host the tenant is FORCED from the resolved Site BEFORE the handler — the same server-supplied, host-derived tenant the file/base carves trust.
Every other ingest route (/v1/ingest, /v1/analytics{,/batch}, /v1/tracker, /v1/insights/e) is a thin alias/shim that resolves org its own way and funnels through the SAME decode + write core. One write path, many doors.
forward.go is the fan-out seam of the canonical event plane. After the ONE write core (ingestEvents) commits a batch to hanzo.events, it hands a COPY of that batch to an optional downstream sink — the destinations subsystem — which translates and forwards each event to the org's connected ad/analytics platforms (GA4, Meta CAPI, …). The seam is:
- ONE-WAY. analytics never imports destinations; destinations calls SetSink from its Mount. A nil sink means no fan-out (the default when destinations is off), so this file changes nothing about ingest when the subsystem is absent.
- RAW. The sink receives the event BEFORE the warehouse privacy scrub, because a server-side Conversions-API forwarder must hash the match keys (email/phone/ click ids) the warehouse deliberately drops. The org connected the destination and owns that consent; the destination adapters SHA-256 every PII field before it leaves the process.
- FAIL-SOFT. The sink runs detached (a panic-guarded goroutine) so a slow or broken destination can never block, fail, or crash an ingest.
public.go — the ANONYMOUS lane of the ONE canonical event door.
A logged-out visitor on a marketing surface carries no bearer and no key, so eventTenant resolves nothing. This file is the lane such a request falls through to, so a pageview or a browser error from a logged-out page lands in the warehouse instead of being refused.
Anonymous input is attested by nobody. It is therefore admitted under a policy the vouched-for lane never applies, and the two lanes are SEPARATE FUNCTIONS rather than one function with a mode flag: eventTenant → ingestBody is untouched by anything in this file, and every restriction below is unreachable from it.
The policy is in two pieces and no more: the request-scoped gates (publicIngest) and the pure decision (admitPublic).
- TENANT is publicTenant, a compile-time constant. admitPublic takes no request, so no header, query, or body field can influence the tenant an anonymous row carries: an anonymous write CANNOT land in a real org's partition, and there is no input that makes it do so.
- KIND is an ALLOWLIST of two — pageview and error, what a marketing surface emits. `identify` and `group` (which name a person and a group) and every custom event are dropped, counted in the honest receipt, never stored.
- NAME is server-chosen FROM the kind ($pageview | $error), so the anonymous name space is closed to two values: an anonymous caller can introduce neither a new name into the read lenses nor unbounded cardinality into the table's ORDER BY key.
- FIELDS are a PROJECTION, not a filter: admitPublic builds a fresh CaptureEvent from the fields it names, so a field it does not name — personId, groupId, revenue, productId, refCode, signupWeek, and the entire client property bag — cannot reach the row. The only properties an anonymous row carries are the server-folded $exception and the write core's $source.
- BYTES and COUNT are bounded first, and REFUSED rather than truncated.
- RATE is capped per client IP and, independently, per socket peer.
- DNT / Sec-GPC on the wire is honored: nothing is stored and the receipt says so.
Everything admitted here flows through the SAME ONE write core (ingestEvents) into the SAME hanzo.events table. One write path; this file only decides what a caller nobody vouched for may put on it.
publishable.go — the public capture path: a PUBLISHABLE KEY (pk-…) attributes a browser beacon to its tenant and writes straight to the datastore.
POST /v1/ingest body: {batch:[WireEvent]} auth: pk-… -> {accepted,dropped}
GET /v1/errors recent type:'error' events for the org (read lens)
ONE publishable key, and IAM issues it. pk- is publishable, sk- is secret, and there is no third thing.
This file used to mint and verify its OWN pk_ (underscore) under an HMAC of CLOUD_INGEST_KEY_SECRET, with its own mint endpoint at /v1/ingest/keys — a second publishable-key family sitting beside the one IAM already owned. The underscore was load-bearing back then: pk_ was deliberately kept OUT of isAPIKey's set, because anything isAPIKey resolved into "the same principal a JWT yields", and a key meant for a browser bundle must not read.
That is fixed at the boundary instead of routed around: IdentityFromRequest now refuses a pk- outright (cloud.IsPublishableKey), so publishable means publishable no matter which door it arrives at. A pk- stays inside APIKeyPrefixes on purpose — OrgForKey must resolve it to learn which tenant a beacon belongs to. Resolvable, not authenticating.
The tenant is whatever IAM resolves the key to, never a body or header claim, so the tenant invariant the rest of the plane enforces holds here too. Every door funnels through the SAME write core (ingestEvents) into the SAME hanzo.events table: one write path, many front doors.
Pure core of the analytics lens: SQL predicate builders, datastore value coercers, and the pure assemblers that turn raw datastore rows into the response structs. Everything here is I/O-free so the tests drive it with mock rows — no datastore needed — exactly as ai/object/cloud_usage.go proves out its Overview assembler. The handlers (analytics.go) are the thin orchestration that fetches the rows and calls these.
THE ONE TENANCY INVARIANT lives here: llmWhere / eventsWhere ALWAYS emit "… = ?" with the org bound POSITIONALLY (never interpolated), so no query this package builds can read a tenant other than the caller's, and a hostile org slug can never escape into SQL. The isolation test asserts this directly.
Index ¶
- func EnsureEventsTable(ctx context.Context) error
- func Mount(app cloud.Router, deps cloud.Deps) error
- func SetSink(fn func(org string, evs []SinkEvent))
- type Breakdown
- type BreakdownRow
- type CampaignEvents
- type CaptureBatch
- type CaptureEvent
- type CaptureResult
- type CommerceOverview
- type Event
- type Exception
- type LLMOverview
- type ModelRow
- type Overview
- type ProductRow
- type Scope
- type SeriesPoint
- type SinkEvent
- type SubjectOutcome
- type Timeseries
- type Top
- type TopModels
- type TopProducts
- type UTM
- type WebOverview
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func EnsureEventsTable ¶ added in v1.800.1
EnsureEventsTable creates hanzo.events if absent. Idempotent; only latches on success so a transient datastore outage at first-write does not poison retries. The writer owns this DDL (the read side deliberately never creates the table).
Types ¶
type Breakdown ¶ added in v1.801.186
type Breakdown struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Items []BreakdownRow `json:"items"`
Source string `json:"source"`
}
Breakdown is a ranked behavior lens over hanzo.events. Honest-empty (Available=false) when the events table is absent/errored — never fabricated.
type BreakdownRow ¶ added in v1.801.186
type BreakdownRow struct {
Key string `json:"key"`
Pageviews int64 `json:"pageviews"`
Visitors int64 `json:"visitors"`
Pct float64 `json:"pct"` // share of total pageviews in-window, 0..100
}
BreakdownRow is one bucket of a behavior lens (a path, a referrer domain, a utm source). pct is the bucket's share of TOTAL pageviews in-window (the window-fn denominator), so a top-N list honestly shows the long tail rather than re-normalizing to the shown rows.
type CampaignEvents ¶ added in v1.801.186
type CampaignEvents struct {
Available bool `json:"available"`
Impressions int64 `json:"impressions"`
Clicks int64 `json:"clicks"`
Conversions int64 `json:"conversions"`
Revenue float64 `json:"revenue"`
Visitors int64 `json:"visitors"`
Source string `json:"source"`
}
CampaignEvents is the per-campaign funnel read from hanzo.events, scoped to (org, utm_campaign[, utm_content]). Impressions/clicks/conversions are counts of the campaign's tagged events; Available is false (honest-empty) when the events warehouse is not connected or the events table is not yet provisioned — never fabricated. Spend is deliberately absent: it is the channel connector's reported number, joined by the campaign plane, not an analytics value.
func CampaignMetrics ¶ added in v1.801.186
func CampaignMetrics(ctx context.Context, org, campaignID, variant string, start, end time.Time) (CampaignEvents, error)
CampaignMetrics reads the (org, campaignID) funnel from the ONE analytics warehouse. variant=="" reads the whole campaign (all creatives); a non-empty variant reads a single creative's slice (utm_content) — the evidence read for a creative A/B. It degrades to honest-empty (Available=false, nil error) when the datastore is not connected, so a campaign metrics view still renders its spend + channels. A genuine query failure against a connected warehouse returns the error (the caller logs it and shows honest-empty) — never a fabricated funnel.
type CaptureBatch ¶ added in v1.800.1
type CaptureBatch struct {
Batch []CaptureEvent `json:"batch"`
Events []CaptureEvent `json:"events"`
}
CaptureBatch is the ingest envelope. `batch` is canonical; `events` is accepted as an alias so a Segment-shaped client works unchanged.
type CaptureEvent ¶ added in v1.800.1
type CaptureEvent struct {
MessageID string `json:"messageId"` // client idempotency id; server mints one if empty
Type string `json:"type"` // pageview | event | identify | group
Event string `json:"event"` // event name (type=event); pageview→$pageview
Timestamp string `json:"timestamp"` // RFC3339; clamped to server-now on skew/absent
DistinctID string `json:"distinctId"` // resolved person/visitor id
AnonymousID string `json:"anonymousId"`
PersonID string `json:"personId"`
SessionID string `json:"sessionId"`
Product string `json:"product"` // emitting surface: console|chat|app|site|admin
URL string `json:"url"`
Path string `json:"path"`
Referrer string `json:"referrer"`
UTM UTM `json:"utm"`
RefCode string `json:"refCode"`
Channel string `json:"channel"`
GroupID string `json:"groupId"`
SignupWeek string `json:"signupWeek"`
ProductID string `json:"productId"`
Quantity uint32 `json:"quantity"`
Revenue float64 `json:"revenue"`
Currency string `json:"currency"`
Error *Exception `json:"error"` // set on type:'error' events (folded into properties.$exception)
Properties map[string]any `json:"properties"`
Library string `json:"library"`
LibraryVer string `json:"libraryVersion"`
}
CaptureEvent is one client-emitted analytics event. The client sends a batch of these; the server owns the tenant (tenant_id is NOT a field here — it can never be set by the client).
type CaptureResult ¶ added in v1.800.1
CaptureResult is the honest receipt: persisted vs dropped (unroutable) counts.
type CommerceOverview ¶
type CommerceOverview struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Orders int64 `json:"orders"`
Revenue float64 `json:"revenue"`
AOV float64 `json:"aov"` // revenue/orders
Source string `json:"source"`
}
CommerceOverview is the commerce lens over hanzo.events. Honest-empty until commerce emits order events.
type Event ¶ added in v1.801.91
type Event struct {
Event string `json:"event"` // event name (required; empty ⇒ dropped as unroutable)
DistinctID string `json:"distinctId"` // the person/visitor id the caller owns
Time string `json:"time"` // optional RFC3339; clamped to server-now on skew/absent
Properties map[string]any `json:"properties"` // everything non-core
}
Event is the canonical analytics event — the entire ingest contract in four fields. Only these are first-class; everything else a caller wants to record travels in Properties (the scrubber runs over it downstream, same as every event). The tenant is NOT a field: it is resolved server-side from IAM, so a caller can only ever write into its OWN org's partition.
type Exception ¶ added in v1.801.150
type Exception struct {
Type string `json:"type,omitempty"`
Message string `json:"message"`
Stack string `json:"stack,omitempty"`
Handled *bool `json:"handled,omitempty"`
}
Exception is the captured error carried on a type:'error' WireEvent (mirrors @hanzo/event's Exception). The ingest folds it into properties.$exception so the ONE events schema needs no new columns and the /v1/errors lens can surface it straight from the properties JSON.
type LLMOverview ¶
type LLMOverview struct {
Available bool `json:"available"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
SpendCents int64 `json:"spendCents"`
Models int64 `json:"models"`
Providers int64 `json:"providers"`
Errors int64 `json:"errors"`
ErrorRate float64 `json:"errorRate"` // 0..1, errors/requests
Source string `json:"source"`
}
LLMOverview is the flagship lens: real per-org KPIs from hanzo.cloud_usage.
type Overview ¶
type Overview struct {
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Interval string `json:"interval"`
Scope Scope `json:"scope"`
LLM LLMOverview `json:"llm"`
Web WebOverview `json:"web"`
Commerce CommerceOverview `json:"commerce"`
}
type ProductRow ¶
type SeriesPoint ¶
type SinkEvent ¶ added in v1.801.186
type SinkEvent struct {
MessageID string
Name string
DistinctID string
AnonymousID string
Time time.Time
URL string
Path string
Referrer string
Revenue float64
Currency string
ProductID string
Quantity uint32
Properties map[string]any
}
SinkEvent is one accepted event handed to the downstream fan-out. It carries the resolved canonical name plus the commerce + identity fields a conversion needs; Properties is the RAW (pre-scrub) property bag the translator lifts match keys and custom data from. The tenant is the org argument to the sink, never a field here.
type SubjectOutcome ¶ added in v1.801.186
SubjectOutcome is one subject's (distinct_id's) participation in an experiment window: whether it fired the Exposed (enrolled / saw the arm) event and whether it fired the Converted (metric) event. It is the per-subject grain the experiments primitive joins to a flags variant assignment to produce per-variant samples.
func Outcomes ¶ added in v1.801.186
func Outcomes(ctx context.Context, org, exposureEvent, metricEvent string, start, end time.Time) ([]SubjectOutcome, error)
Outcomes returns, for one org over [start,end), each subject's exposure + conversion for an experiment's two event names, read from hanzo.events. It is the measurement seam the experiments primitive composes: flags assignment joins to these outcomes by distinct_id.
TENANT ISOLATION is the eventsWhere invariant — org is bound POSITIONALLY, never interpolated — and every event name is a BOUND parameter too, so neither a hostile org slug nor a hostile event name can escape into SQL. exposureEvent may be "" (then every returned subject is Exposed: the population is "appeared in-window"); metricEvent is required. Fails closed with a 503 when the warehouse is absent.
type Timeseries ¶
type Top ¶
type Top struct {
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Scope Scope `json:"scope"`
Models TopModels `json:"models"`
Products TopProducts `json:"products"`
// Behavior lenses (events lens): WHERE people go / WHAT they look at
// (topPages) and where they come FROM (topReferrers organic/referral,
// topSources campaigns). Honest-empty until the beacon fills hanzo.events.
Pages Breakdown `json:"topPages"`
Referrers Breakdown `json:"topReferrers"`
Sources Breakdown `json:"topSources"`
}
type TopProducts ¶
type TopProducts struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Items []ProductRow `json:"items"`
Source string `json:"source"`
}
type UTM ¶ added in v1.800.1
type UTM struct {
Source string `json:"source"`
Medium string `json:"medium"`
Campaign string `json:"campaign"`
Term string `json:"term"`
Content string `json:"content"`
}
UTM is the first-touch attribution the client persists and re-sends per event.
type WebOverview ¶
type WebOverview struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Pageviews int64 `json:"pageviews"`
Visitors int64 `json:"visitors"`
Sessions int64 `json:"sessions"`
Source string `json:"source"`
}
WebOverview is the web lens over hanzo.events. Honest-empty (Available=false) until the collector emits web events.