Documentation
¶
Overview ¶
Analytics entitlement contract.
This file is the ONE place cloud maps a Hanzo plan to the access that gates the RICH per-org usage-analytics surface. It does NOT introduce a second datastore and does NOT touch the metering WRITE path (the hanzoai/ai router still writes every AI call into the ONE global hanzo.cloud_usage ledger; the org column is the row-level tenant filter). It gates who may READ the rich lens over that ledger.
The split it enforces:
- BASIC own-org usage (GET /v1/usage/summary) is ALWAYS allowed — every tenant can read its own footprint. Never gated here.
- RICH per-org analytics/insights (retention window, exports, per-provider/BYO/ fee breakdowns — GET /v1/usage/analytics) is a PAID entitlement, gated here.
The values are NOT hardcoded: they are resolved from the @hanzo/plans catalog (the single source of truth) via the canonical `analytics.*` entitlement vocabulary, so the numbers live in exactly one place and are read, never duplicated.
Contract (keys to add to hanzoai/plans entitlements.schema.json):
analytics.datastore boolean access to the rich per-org analytics surface analytics.retention_days int how far back the tenant may query analytics.export boolean data export allowed
FOLLOW-UP (documented, coordinated — not built here). Per-request ENFORCEMENT needs the caller's effective plan id (org -> plan), which is a subscription lookup owned by the billing plane (commerce /v1/billing/subscriptions); the gateway principal carries org/project but NO plan claim today, and no org->plan resolver exists in clients/plan or clients/billing yet. Until it lands, ResolveAnalyticsAccess takes an explicit plan id and GET /v1/usage/analytics/access echoes any plan's access so dashboards self-config against the live catalog, and GET /v1/usage/analytics reads ?plan= for its gate. Wiring the resolver into the handler (swap the ?plan= line for the resolved caller-org plan) is the rollout step — the gate logic is unchanged by it.
Pure core of the usage summary: the response shape, the ledger→category mapper, the spend roll-up + gap-filled series assembler, and the ClickHouse value coercers. Everything here is I/O-free so the tests drive it with plain structs and mock rows — no commerce, no ClickHouse. The handler (usage.go) is the thin orchestration that fetches and calls these.
Package usage mounts the Hanzo Cloud UNIFIED USAGE surface (GET /v1/usage/summary): one org-scoped roll-up answering "what am I running and what does it cost" — the flagship footprint view. It is a native-Go composition over the two canonical shared cost sources, and exists because NO single endpoint aggregates this today:
- Spend (the genuinely-missing categorized cost roll-up): the commerce ledger — usage-rollup (authoritative month-to-date consumed + prepaid wallet) plus the raw transaction ledger, rolled up server-side into spend-by-category over time. Every metered resource (GPU, machine-hours, LLM tokens, datastore footprint) debits this ONE ledger with a category tag, so it is already the unified cost source — this endpoint is the categorizing lens over it.
- LLM usage totals: hanzo.cloud_usage, the per-org warehouse ledger (the same table /v1/analytics/* and the o11y board read). Totals only here — the per-model / timeseries detail stays at /v1/analytics/*.
The console Usage view composes THIS (cost roll-up + LLM totals) with the existing org-scoped inventory endpoints (/v1/machines, /v1/gpus, /v1/agents, provisioning lists) for the per-kind counts — one screen, this one authoritative money source.
TENANT ISOLATION (the bar). The org is the VALIDATED IAM owner claim (principal.Org — the trusted X-Org-Id the identity middleware minted from the caller's verified bearer, HIP-0026; NEVER a client header) AND a validated principal is required (c.User() set only for a verified bearer). The commerce subject is pinned server-side to that org; the warehouse query binds it POSITIONALLY. A caller can only ever read its OWN org. Fail-closed: no principal → 401. Every source degrades independently to honest zeros (source markers say which answered) — a partial deploy never fabricates spend or usage.
Registered as "usagesvc" (NOT "usage") + order 131: the name diverges from the /v1/usage route so serve.go's generic GET /v1/<name>/health parks at /v1/usagesvc/health and never shadows the summary. Order 131 binds /v1/usage/* before the ai subsystem's /v1/* catch-all (150).
Index ¶
Constants ¶
This section is empty.
Variables ¶
var FreeAnalyticsAccess = AnalyticsAccess{Datastore: false, RetentionDays: 7, Export: false}
FreeAnalyticsAccess is the fail-closed floor: basic own-org usage only — no rich datastore surface, a 7-day query window, no export. It is applied whenever no plan can be resolved (unknown plan, or the catalog is unreachable), so a catalog outage degrades to exactly Free — never above.
Functions ¶
Types ¶
type AnalyticsAccess ¶ added in v1.786.146
type AnalyticsAccess struct {
Datastore bool // analytics.datastore
RetentionDays int // analytics.retention_days
Export bool // analytics.export
}
AnalyticsAccess is the resolved analytics entitlement for one caller. Datastore grants the rich per-org analytics/datastore surface; RetentionDays caps how far back the tenant may query; Export allows data export.
func AnalyticsAccessFromEntitlements ¶ added in v1.786.146
func AnalyticsAccessFromEntitlements(ent map[string]any) AnalyticsAccess
AnalyticsAccessFromEntitlements maps a resolved `analytics.*` entitlement block onto AnalyticsAccess. Pure and total: any key absent from the block keeps the Free-floor value, so a partial catalog can only ever NARROW access (never widen past the floor). Wrong-typed values are ignored (kept at floor).
func ResolveAnalyticsAccess ¶ added in v1.786.146
func ResolveAnalyticsAccess(ctx context.Context, planID string) (AnalyticsAccess, error)
ResolveAnalyticsAccess resolves analytics access for a plan id from the @hanzo/plans catalog (the single source of truth). An empty id short-circuits to FreeAnalyticsAccess (the floor) WITHOUT a catalog round-trip, so an unauthenticated or plan-less caller fails closed and CI stays catalog-independent. On any resolution error it returns FreeAnalyticsAccess together with the error, so callers fail closed while logging the degradation.
type AnalyticsView ¶ added in v1.786.146
type AnalyticsView struct {
Scope Scope `json:"scope"`
Plan string `json:"plan"`
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
RetentionDays int `json:"retentionDays"`
Export bool `json:"export"`
Providers ProviderBreakdown `json:"providers"`
}
AnalyticsView is the entitlement-gated rich read: the per-provider breakdown of the org's LLM usage over the retention-clamped window, plus the plan's retention + export decision so the console renders the right controls.
type CategorySpend ¶
type CategorySpend struct {
Category string `json:"category"`
Cents int64 `json:"cents"`
Count int64 `json:"count"`
}
CategorySpend is one row of the spend-by-category breakdown: a friendly category bucket, the total cents spent in the window, and how many ledger lines rolled up into it. Categories are DERIVED from the commerce ledger's own tags — never fabricated — so an unknown tag surfaces as its own honest bucket.
type LLM ¶
type LLM struct {
Available bool `json:"available"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
CostCents int64 `json:"costCents"`
Models int64 `json:"models"`
Source string `json:"source"`
}
LLM is the org's LLM usage totals from the warehouse ledger. Available=false when the datastore is not connected (honest zeros). The detailed per-model / timeseries breakdown lives at /v1/analytics/*; this is the KPI-band total.
type ProviderBreakdown ¶ added in v1.786.146
type ProviderBreakdown struct {
Available bool `json:"available"`
Items []ProviderRow `json:"items"`
Source string `json:"source"`
}
ProviderBreakdown is the per-provider roll-up. Available=false is honest-empty (the datastore is not connected) — never fabricated, exactly like the summary's LLM block.
type ProviderRow ¶ added in v1.786.146
type ProviderRow struct {
Provider string `json:"provider"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
CostCents int64 `json:"costCents"`
}
ProviderRow is one provider's windowed totals. BYO/fee/account columns land with the hanzoai/ai metering-write branch; see buildAnalyticsBlock for the one-line projection extension.
type Sources ¶
Sources reports which upstreams actually answered, so the console can badge a board as "connected" vs "no data yet" honestly instead of showing a fabricated zero as if it were real.
type Spend ¶
type Spend struct {
Available bool `json:"available"`
TotalCents int64 `json:"totalCents"`
MTDCents int64 `json:"mtdCents"`
OverageCents int64 `json:"overageCents"`
BalanceCents int64 `json:"balanceCents"`
AvailableCents int64 `json:"availableCents"`
ByCategory []CategorySpend `json:"byCategory"`
Series []SpendPoint `json:"series"`
Source string `json:"source"`
}
Spend is the cost roll-up: the genuinely-missing aggregation. TotalCents is the windowed consumption (self-consistent with ByCategory + Series); MTDCents is commerce's authoritative month-to-date figure; Balance/Available are the prepaid wallet the gateway debits. Available=false means commerce was unconfigured or unreachable — honest zeros, never fabricated spend.
type SpendPoint ¶
type SpendPoint struct {
T string `json:"t"` // RFC3339 bucket start (UTC)
Cents int64 `json:"cents"`
}
SpendPoint is one time bucket of consumption (usage/withdrawal cents).
type Summary ¶
type Summary struct {
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Interval string `json:"interval"`
Scope Scope `json:"scope"`
Spend Spend `json:"spend"`
LLM LLM `json:"llm"`
Sources Sources `json:"sources"`
}
Summary is the whole org-scoped usage footprint roll-up: the cost roll-up (spend) + the LLM usage totals, over one window, for one org.