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 datastore value coercers. Everything here is I/O-free so the tests drive it with plain structs and mock rows — no commerce, no datastore. The handler (usage.go) is the thin orchestration that fetches and calls these.
Package usage is the ONE Hanzo Cloud usage surface (/v1/usage). It owns ALL usage — clients/link owns links and nothing usage — as one coherent plane over one window grammar:
- POST /v1/usage record account-usage samples (the collector's data plane; write path over the hanzo.account_usage warehouse series, datastore.go).
- GET /v1/usage/samples one provider account's own lane dash (the time series).
- GET /v1/usage/summary the flagship own-scoped footprint roll-up (below).
- GET /v1/usage/analytics{,/access} the entitlement-gated rich per-org read.
The summary answers "what am I running and what does it cost" by composing THREE complementary sources, each degrading independently to honest zeros (a source marker says which answered — a partial deploy never fabricates spend or usage):
- 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 account board: the caller's OWN linked provider accounts (a Claude Max plan's window %, metered from the provider's own login) beside the org's Hanzo-routed usage, every row labelled by source/scope and NEVER summed. This is the account-usage global view, unified here from clients/link.
The console Usage view composes THIS 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; every warehouse query binds org (and, for the account board, subject) POSITIONALLY. A caller can only ever read/write its OWN org — fail-closed: no principal → 401.
Registered as "usage" (order 131, OwnsHealth=false): the generic GET /v1/usage/health liveness route is a distinct path and never shadows these; order 131 binds /v1/usage/* before the ai subsystem's /v1/* catch-all (150).
Index ¶
- Constants
- Variables
- func Mount(app cloud.Router, deps cloud.Deps) error
- type Accounts
- type AnalyticsAccess
- type AnalyticsView
- type CategorySpend
- type LLM
- type ProviderBreakdown
- type ProviderRow
- type Sample
- type Scope
- type SourceState
- type Sources
- type Spend
- type SpendPoint
- type Summary
- type Total
- type TotalView
Constants ¶
const ( Range1h = "1h" Range24h = "24h" Range7d = "7d" Range30d = "30d" )
Ranges — the closed allowlist for a sample-dash read window. This is the FINE-GRAINED account-usage grammar (a live lane dash cares about the 1h/6h scale); the coarser cost/analytics grammar (aiobject.ResolveCloudUsageWindow: 24h/7d/30d/custom + a bucket interval) drives summary and analytics. Two lanes, two grammars, each complete for its read.
const ( Window6h = "6h" WindowDay = "day" WindowWeek = "week" WindowMonth = "month" )
The CANONICAL window value: the ONE closed vocabulary that rate limits, quotas, and usage rollups all share, platform-wide. Lowercase, no case variants, no synonyms — a window is one of exactly these four values.
This declaration is the single source of the vocabulary. (hanzoai/commerce today carries an ad-hoc set — weekly/daily/monthly/hourly plus capitalized variants, and no sub-day window at all; a later commerce pass adopts THESE values. Do not copy the old strings back in.)
const ( ConfidenceExact = "exact" ConfidenceEstimated = "estimated" ConfidencePercentOnly = "percentOnly" ConfidenceUnknown = "unknown" )
Confidence values — how real a sample's numbers are. These mirror @hanzo/usage's UsageDataConfidence EXACTLY (they are the meter's own wire values, already carried on Usage.Confidence), so a value crosses the whole system unmapped.
This is the ANTI-FABRICATION FLAG, and the reason it is a column: a Claude sample is `percentOnly`, so its token counters are 0 because they are UNKNOWN — not because the account consumed nothing. Without this value a reader cannot tell "0 tokens" from "no idea", and would render a fabricated zero. With it, the console renders "—".
const ( SourceAccount = "account" SourceHanzo = "hanzo" )
Sources for the global view: which plane a usage row came from. They are NOT interchangeable and are never added together —
- SourceAccount the provider's OWN plan consumption, metered from the user's own login. Its cost is what the PROVIDER says it charged (0 for a flat subscription); its percent is plan quota. NOT a Hanzo charge.
- SourceHanzo hanzo.cloud_usage — Hanzo-routed inference. COST OF RECORD.
const ( ScopeUser = "user" // the caller's OWN linked accounts (org+subject) ScopeOrg = "org" // the whole org's Hanzo-routed usage )
Scopes for a global-view row: the tenancy the row's numbers cover. The two sources answer at different scopes and the row says which, so a reader never silently compares a user's plan usage against an org's whole spend.
const ( KindSubscription = "subscription" KindAPIKey = "apikey" )
Account-kind vocabulary — how a metered account is credentialed. It mirrors the link registry's Kind (subscription | apikey) so a Sample and its Link agree, but each package validates its OWN inputs: the value is a fact about the account, not a shared place, so the two are decoupled and neither can force a change on the other.
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 Accounts ¶
type Accounts struct {
Rows []TotalView `json:"rows"`
Account SourceState `json:"account"`
Hanzo SourceState `json:"hanzo"`
}
Accounts is the account-usage board folded into the summary: the caller's OWN linked provider accounts (metered from each provider's own login — a Claude Max plan's window %) beside the org's Hanzo-routed usage. Every row is labelled by source/scope/confidence and the two are NEVER summed together — a plan's percent is not money, and a provider's own spend is not a Hanzo charge. Each side reports its own availability, so half a warehouse never turns the other half into zeros. This is the account-usage plane's global view (moved from clients/link) unified under the ONE /v1/usage/summary; the per-account time series is GET /v1/usage/samples.
type AnalyticsAccess ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 Sample ¶
type Sample struct {
Provider string // matches Link.Provider / @hanzo/usage providerRegistry id
Account string // the account label (an identifier, never a secret)
Plan string // the plan name from the provider identity; display only
Kind string // subscription | apikey (validKind) — mirrors Link.Kind
Machine string // the machine that OBSERVED this (an attribute, not a key)
Lane string // the meter's lane id (five_hour|seven_day_opus|…) — the identity
Window string // the canonical class (6h|day|week|month)
WindowMinutes int32 // the duration the meter reported (300 for Claude's 5h)
WindowStart time.Time // WHICH instance of the window this measures — the dedup key
ResetsAt time.Time // when the window resets (RateWindow.resetsAt); zero = unknown
// UsedPct is the lane's used percent, 0..100 — RateWindow.usedPercent. For a
// subscription account this is THE quota signal and often the ONLY one.
//
// QUOTA GAP (deliberate, reported, not faked): there is NO absolute
// used/limit token quota column, because nothing can populate one. The meter
// reports a percent, not a limit (Claude: dataConfidence `percentOnly`). The
// hanzoai/plans catalog expresses `ai.requests_per_min` / `ai.tokens_per_min`
// — per-MINUTE rate limits, a different concept from a window quota — and in
// any case it is HANZO's own plan catalog, which cannot know what Anthropic
// grants a Claude Max plan. An always-zero quota_limit would read as "the
// limit is zero"; absent is honest. It lands as an additive column when a
// source for it exists.
UsedPct float64
Confidence string // exact|estimated|percentOnly|unknown — see the const block
// Synthetic marks a lane the meter FABRICATED (RateWindow.isSyntheticPlaceholder
// — set when a provider returned null for a lane and the adapter filled it in).
// Carried so a made-up lane is never rendered as observed truth.
Synthetic bool
// Absolute counters — UsageTotals. Present only when the source really reports
// them (`exact`); zero otherwise, which Confidence disambiguates.
Requests int64
InputTokens int64
OutputTokens int64
TotalTokens int64
CachedInputTokens int64
// Money — ProviderCostSnapshot, in minor units (cents). CostCents is what the
// PROVIDER says this account spent (Claude's extra_usage overage; 0 for a flat
// subscription — the plan is the charge). CostLimitCents is that meter's budget
// (ProviderCostSnapshot.limit) — a MONEY cap, never a token quota. Neither is a
// Hanzo charge: this plane holds no metering client.
CostCents int64
CostLimitCents int64
Currency string // ProviderCostSnapshot.currencyCode; "" = unknown
}
Sample is one metering lane's consumption of one provider account at one observation.
TENANCY IS NOT ON THE VALUE. Org and subject are bound by the server from the validated principal at the boundary, so a client cannot assert whose usage this is — the shape makes cross-tenant writes unrepresentable rather than merely refused.
func (Sample) Sanitize ¶
Sanitize bounds every field so a warehouse row stays small, finite, and well-formed no matter what a client sends: strings trimmed and length-capped, counters non-negative and clamped, percents coerced into [0,100] even for NaN/Inf, instants bounded to a sane window around `now`, and the two enums defaulted rather than trusted. It is TOTAL (never errors), so the write path always has a safe value — validation of the CLOSED vocabularies (window, kind) is the boundary's job and is a 400, because silently rewriting a caller's window class would corrupt their dash.
It does NOT set the observation clock: the server stamps that, so a client can never backdate a sample or pin a stale one as newest.
type SourceState ¶
type SourceState struct {
Available bool `json:"available"`
Scope string `json:"scope"`
Source string `json:"source"` // the table of record
Note string `json:"note"`
}
SourceState is one side of the account board's availability + labelling: which ledger answered, at what scope, and a human note.
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"`
Accounts Accounts `json:"accounts"`
Sources Sources `json:"sources"`
}
Summary is the whole own-scoped usage footprint roll-up over one window: the cost roll-up (spend) + the org's LLM usage totals (llm) + the caller's linked-account board (accounts). Spend/LLM are org-scoped; the account board is the caller's own (org+subject). One screen, one authoritative money source, one window.
type Total ¶
type Total struct {
Source string
Scope string
Provider string
Window string
Requests int64
Tokens int64
CostCents int64
UsedPct float64
Confidence string
Windows int64
}
Total is one provider's usage in the global view, from ONE source at ONE scope.
type TotalView ¶
type TotalView struct {
Source string `json:"source"` // account | hanzo
Scope string `json:"scope"` // user | org
Provider string `json:"provider"`
Window string `json:"window,omitempty"`
Requests int64 `json:"requests,omitempty"`
Tokens int64 `json:"tokens,omitempty"`
CostCents int64 `json:"costCents,omitempty"`
UsedPct float64 `json:"usedPct,omitempty"`
Confidence string `json:"confidence"`
Windows int64 `json:"windows,omitempty"`
}
TotalView is one row of the account-usage board (the summary's `accounts.rows`). Source and scope are what keep the board honest — see the Source/Scope consts.