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) + top products (honest-empty) 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).
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] -> {accepted, dropped}
A JSON object is one event; a JSON array IS the batch (there is deliberately no /v1/event/batch). Every other ingest surface (the PostHog wire at /v1/insights/e, the Segment/beacon wire at /v1/analytics{,/batch} and /v1/tracker) is a thin DEPRECATED adapter that normalizes its own wire shape onto CaptureEvent and funnels through the SAME write core (ingestEvents) into the SAME hanzo.events table. One write path, many adapters.
AUTH — IAM ONLY, FAIL-CLOSED: the tenant is resolved SERVER-SIDE from a validated bearer principal (its owner org) or, for a keyed bearer-less SDK, an access key resolved through the ONE IAM key seam (cloud.OrgForKey). There is NO brand-host fallback on this endpoint: an unauthenticated or unresolvable caller is refused (403), so the canonical door never writes an event into a tenant IAM did not vouch for. The org is NEVER read from the body.
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
- type CaptureBatch
- type CaptureEvent
- type CaptureResult
- type CommerceOverview
- type Event
- type LLMOverview
- type ModelRow
- type Overview
- type ProductRow
- type Scope
- type SeriesPoint
- 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 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"`
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 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 Timeseries ¶
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.