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 a second connection: it rides the SAME datastore-go/v2 client the ai subsystem's o11y ledger opens in the shared Bootstrap (ai/object.InitDatastore → object.DatastoreQuery). DRY: one transport, one pool, one set of KMS-injected DATASTORE_* creds — never hard-coded, never a second design.
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 write-only publishable key (pk_…) — HMAC-verified org, no IAM/DB hop (the 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).
None of the above ⇒ 403. 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 tenant IAM did not vouch for. The org is NEVER read from the body.
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.
publishable.go — the FASTEST capture path: a write-only PUBLISHABLE KEY (pk_…) that authenticates a direct-to-datastore ingest with ZERO network hop.
POST /v1/ingest body: {batch:[WireEvent]} auth: pk_… -> {accepted,dropped}
POST /v1/ingest/keys mint a pk_ for the caller's org (validated principal)
GET /v1/errors recent type:'error' events for the org (read lens)
WHY a distinct key from the IAM hk-/sk-/pk- family: those resolve through IAM (get-user?accessKey — a network round-trip) and mint a FULL principal that can READ. A publishable key is meant to ship in a browser bundle, so it must be write-only and cheap to verify. This key is:
- INGEST-ONLY BY CONSTRUCTION. The `pk_` (underscore) prefix is deliberately NOT in isAPIKey's set (hk-/sk-/pk-/fw_/hz_, all dash/`fw_`/`hz_`), so the identity boundary (SanitizeIdentity) and OrgForKey both REFUSE it — it can never become a bearer principal, so it can never read. Its only door is the ingest verifier below. Write-only is a property of WHICH resolver accepts the value, not a flag on a row.
- ORG-SCOPED, SIGNED, NON-FORGEABLE. The org is carried in the key but sealed under HMAC-SHA256(secret, org): a client cannot flip the org without the secret. The server stamps tenant_id from the VERIFIED org, never from the request body — the same tenant invariant the rest of the plane enforces.
- LOWEST LATENCY. Verification is one HMAC compute — no IAM call, no keys table, no DB read. This is the no-Kafka, no-bridge, direct-to-ClickHouse path; it funnels through the SAME write core (ingestEvents) into the SAME hanzo.events table as every other adapter. One write path, many front doors.
SECRET: the HMAC secret is CLOUD_INGEST_KEY_SECRET (KMS-injected by the operator). Absent ⇒ mint and verify BOTH fail closed (503 / 403) — a deployment without the secret never mints a forgeable key nor admits an unverifiable one.
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 *zip.App, 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.