analytics

package
v1.801.108 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: Apache-2.0 Imports: 21 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 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

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 *zip.App, deps cloud.Deps) error

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

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

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 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 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"`
}

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