analytics

package
v1.801.242 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

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:

  1. a validated IAM bearer principal — its owner org;
  2. 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);
  3. 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

Constants

This section is empty.

Variables

This section is empty.

Functions

func EnsureEventsTable added in v1.800.1

func EnsureEventsTable(ctx context.Context) error

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).

func Mount

func Mount(app cloud.Router, deps cloud.Deps) error

Mount wires the analytics surface onto app per HIP-0106.

func SetSink added in v1.801.186

func SetSink(fn func(org string, evs []SinkEvent))

SetSink installs (nil clears) the downstream fan-out hook.

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

type CaptureResult struct {
	Accepted int `json:"accepted"`
	Dropped  int `json:"dropped"`
}

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 ModelRow

type ModelRow struct {
	Model      string  `json:"model"`
	Provider   string  `json:"provider"`
	Requests   int64   `json:"requests"`
	Tokens     int64   `json:"tokens"`
	SpendCents int64   `json:"spendCents"`
	Pct        float64 `json:"pct"` // share of total spend, 0..100
}

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 ProductRow struct {
	ProductID string  `json:"productId"`
	Orders    int64   `json:"orders"`
	Revenue   float64 `json:"revenue"`
	Units     int64   `json:"units"`
}

type Scope

type Scope struct {
	Org string `json:"org"`
}

type SeriesPoint

type SeriesPoint struct {
	T          string `json:"t"` // RFC3339 bucket start (UTC)
	Requests   int64  `json:"requests"`
	Tokens     int64  `json:"tokens"`
	SpendCents int64  `json:"spendCents"`
}

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

type SubjectOutcome struct {
	Subject   string
	Exposed   bool
	Converted bool
}

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 Timeseries struct {
	Range    string        `json:"range"`
	Start    string        `json:"start"`
	End      string        `json:"end"`
	Interval string        `json:"interval"`
	Scope    Scope         `json:"scope"`
	Series   []SeriesPoint `json:"series"`
	Source   string        `json:"source"`
}

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 TopModels

type TopModels struct {
	Available bool       `json:"available"`
	Items     []ModelRow `json:"items"`
	Source    string     `json:"source"`
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL