Documentation
¶
Overview ¶
Package analytics is product analytics: send an event, read back who did what.
It is the product-event plane: it owns the ingest door every Hanzo client posts to, lands each event in the `hanzo` warehouse, and serves the per-org read lenses — KPIs, time series, rankings, captured errors — over what it wrote.
It is BOTH halves, and that is deliberate: one write core (ingestEvents) behind N doors (event.go), and the read lenses over the same warehouse, so a fact is admitted, stamped with the SERVER-resolved tenant, and read back through one vocabulary. Two lenses share 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: event.fact (signal='act') on the o11y-owned event plane — what this package's own doors ingest (as facts, landed by the sink in warehouse.go).
The accepted batch is also handed to registered SINKS (forward.go) — apps/ destinations forwards it to the org's connected ad platforms. analytics never imports a consumer; the seam is one-way and fail-soft.
ONE datastore client. This package does NOT open its own connection: it reads through apps/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 apps/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
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/errors the caller org's most recently captured errors
GET /v1/insights/events the caller org's most recent product events
GET /v1/insights/health the insights surface is serving
GET /v1/analytics/health subsystem health (datastore connectivity + lens tables)
WRITE (the ingest door — see doors, event.go)
POST /v1/event the canonical wire (object | array | {batch:[…]});
decodeEvent sniffs the PostHog wire here too
POST /v1/event/:project/envelope|store the Sentry error wire, same door
The six reads above /v1/analytics/health are TYPED ops, so each publishes its prose, its In/Out schema, an MCP tool and a CLI command. /v1/analytics/health and the ingest doors are untyped and cannot be typed without moving their wire; routes (below) names each one's blocker where it is registered.
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).
Index ¶
- Constants
- Variables
- func AddSink(fn func(org string, evs []SinkEvent)) (remove func())
- func EnsureEventStream(ctx context.Context, cl *infra.PubSubClient) error
- func HasFallbackKeyResolver() bool
- func Mount(app cloud.Router, deps cloud.Deps) error
- func PublishEvents(org string, evs []SinkEvent)
- func SetFallbackKeyResolver(r KeyResolver)
- func SetKeyResolver(r KeyResolver)
- func Shutdown(context.Context) error
- type Attribution
- type Breakdown
- type BreakdownRow
- type CampaignEvents
- type CaptureBatch
- type CaptureEvent
- type CaptureResult
- type ClipBody
- type CommerceOverview
- type Event
- type EventEnvelope
- type Exception
- type Frame
- type KeyResolver
- type LLMOverview
- type LogBody
- type MetricBody
- type ModelRow
- type Overview
- type ProductRow
- type Scope
- type SinkEvent
- type SpanBody
- type SubjectOutcome
- type Timeseries
- type Top
- type TopModels
- type TopProducts
- type UTM
- type UsagePoint
- type WebOverview
Constants ¶
const EventOrgKey = "org"
EventOrgKey is the ONE field an envelope on this plane names its tenant with. Every consumer resolves the org by reading it, so a publisher that spelled it differently would deliver to nobody rather than fail loudly — which is precisely why the spelling is a constant here, in the plane's own package, and not a string literal at each end.
const EventSignalKey = "signal"
EventSignalKey is the field that says WHICH VOCABULARY a message on this plane speaks, and it exists because two of them share it.
A fact (message, below) carries it; the subscriber-facing EventEnvelope does not. The two are separate contracts by design, and their subject spaces WOULD have kept them apart — event.<signal> is a closed set of five, event.<folded product name> is whatever a caller names an event — except that the fold maps a product event named "$error" straight onto event.error, which is precisely the subject the fact plane's error writer drains, and "$error" is what a browser error with no name of its own is called. An open namespace minting a reserved token is the collision; the fold's mapping is a PUBLISHED contract (orgs subscribe to it) and so cannot be the thing that moves.
So the discriminator is the BODY, not the subject: each consumer takes the vocabulary it speaks and leaves the other alone. The warehouse lands facts and ignores envelopes (warehouse.go); webhooks delivers envelopes and ignores facts (apps/webhooks). It is a constant HERE, in the plane's own package, for the same reason EventOrgKey is — a consumer that spelled it itself would silently take the wrong half.
Variables ¶
var EventStream = strings.ToUpper(plane)
EventStream is the JetStream stream every signal lands on, and EventSubjects is the ONE wildcard it binds. Upper-case is the NATS convention for a stream name; it is the same word as the database and the subject root (plane, fact.go) — one name, three layers.
They are EXPORTED because JetStream enforces single ownership: a second stream binding event.> is refused with "subjects overlap with an existing stream", which takes down whichever subsystem loses the race. So a consumer (apps/webhooks) names THIS stream rather than declaring one of its own — the compiler is what keeps the two from drifting into an outage.
var EventSubjects = []string{plane + ".>"}
EventSubjects is the stream's binding: every signal, present and future. A new signal is a new constant in fact.go and a consumer that filters for it — never a stream edit.
Functions ¶
func AddSink ¶ added in v1.801.350
AddSink registers a downstream fan-out consumer and returns its remover. Every registered sink receives every accepted batch, each on its own detached, panic-guarded dispatch — one seam, N consumers, none of which can block or fail ingest or each other.
func EnsureEventStream ¶ added in v1.801.350
func EnsureEventStream(ctx context.Context, cl *infra.PubSubClient) error
EnsureEventStream RECONCILES the event plane to the configuration this package chose for it, creating it when absent. It is the ONE declaration of that configuration anywhere in the platform.
It is EXPORTED so a consumer can make sure the plane exists before binding a durable to it — a consumer that starts before the first ingest would otherwise find no stream — WITHOUT holding a second copy of the config. Whoever calls it first applies it, and because there is only one config, it does not matter who that is.
RECONCILES, NOT CREATE-IF-MISSING, and the difference is the whole point. A create-if-missing ensure returns success the moment the stream exists and never looks at what it looks like, so every constant below was decorative on a live deployment: raising the ceiling, shortening the hand-off window, or fixing the discard policy changed the source and nothing else, forever. CreateOrUpdateStream applies them.
DISCARD NEW, NOT OLD, which is the config that was silently wrong. On a full stream the JetStream default (DiscardOld) evicts the OLDEST messages to make room — and the oldest messages on a hand-off stream are precisely the ones no consumer has drained yet. That is data the door already answered 200 for, deleted to make room for data the door has not answered for yet, with no error at either end. DiscardNew inverts it: a full stream REFUSES THE PUBLISH, so publishToStream fails, the door answers 503, and the client retries — backpressure the caller can see instead of loss nobody can. The ceiling stops being a silent shredder and becomes what it reads like: a limit.
MaxAge still expires drained-or-not after streamAge. That is the deliberate hand-off window, not a capacity failure, and a consumer down for three days is an outage to alarm on rather than a case to size storage for.
It goes through cl.JetStream() rather than infra.EnsureStream because the wrapper models neither operation this needs: its StreamConfig has no Discard field at all, and it returns early the moment the stream exists. That accessor is exported for exactly this — a caller that needs a capability the thin wrapper does not carry — and this package is the stream's owner, so the full config belongs here in the vocabulary that can express it. The wrapper's own create-if-missing behavior is a platform-wide defect (every other stream in the fleet is declared through it and is equally undeclarable after creation); fixing it there is a change to hanzoai/commerce and to every stream owner at once, which is not this package's to make. A STREAM IS NOT RENAMEABLE, which is the failure this function has to survive. CreateOrUpdateStream reconciles a stream by NAME; JetStream binds subjects by OWNERSHIP. So when the plane's name changes — EVENTS to EVENT, the day apps/webhooks stopped declaring a plane it only consumes — the old stream keeps event.> and the new name can never bind it. Every publish then 503s, forever, and no restart, redeploy or rollback clears it: the store is durable, so the stale stream outlives the code that made it. That is not a race that resolves, it is a deadlock that needs a migration, and a plane that cannot migrate itself is a plane that takes ingest down until somebody notices.
So the ensure RETIRES the earlier generation. See retire for what it will and will not remove.
func HasFallbackKeyResolver ¶ added in v1.801.437
func HasFallbackKeyResolver() bool
HasFallbackKeyResolver reports whether a cross-process resolver is installed, so the host can prove it wired the door. An unwired seam refuses every beacon on the fleet and no test inside this package can see it, because the package is correct either way.
func PublishEvents ¶ added in v1.801.350
PublishEvents puts an accepted batch on the plane, one publish per event, under the batch's SERVER-resolved org. It is the ONE way a product event reaches the platform bus, and it lives HERE because this package owns the plane: the stream, the subject grammar, and the envelope are one decision, and a consumer that also published would be a second owner of all three.
FAIL-SOFT, unlike the fact path above. The batch is already COMMITTED as facts (publish, above) by the time this runs, so a bus that is down for this second vocabulary loses only envelope deliveries, never data — the ingest already answered 200 for the durable copy. It is called detached (forward.go), so a slow bus costs a goroutine and never an ingest.
func SetFallbackKeyResolver ¶ added in v1.801.437
func SetFallbackKeyResolver(r KeyResolver)
SetFallbackKeyResolver installs the cross-process resolver. The composition root calls it with a plane client, for every process that does NOT own the project store — which in production is the one serving this door.
func SetKeyResolver ¶ added in v1.801.437
func SetKeyResolver(r KeyResolver)
SetKeyResolver installs the in-process resolver. projects.Mount calls it with its store — the no-hop answer when ingest and the project store share a process.
Types ¶
type Attribution ¶ added in v1.801.437
Attribution is what a publishable key resolves to: the org that owns the rows, and the project that emitted them.
Project is the SERVER's answer to a question the wire also asks — an event carries a `product` field naming its emitting surface, and that field is the caller's to set. When a key names a project the server's answer wins (see attributeProject), which is the difference between a label and an attribution.
type Breakdown ¶
type Breakdown struct {
// Available is false when the product-event table could not be read.
Available bool `json:"available"`
// Reason says why the lens is unavailable. Omitted when it is available.
Reason string `json:"reason,omitempty"`
// Items is the ranked buckets, most pageviews first. Empty rather than absent.
Items []BreakdownRow `json:"items"`
// Source is the warehouse table the lens read.
Source string `json:"source"`
}
Breakdown is a ranked behavior lens over event.fact. Honest-empty (Available=false) when the events table is absent/errored — never fabricated.
type BreakdownRow ¶
type BreakdownRow struct {
// Key is the bucket: a requested path, a referrer domain ("(direct)" for none or
// a same-origin one), or a utm_source ("(none)" when absent).
Key string `json:"key"`
// Pageviews is how many $pageview events fell in this bucket.
Pageviews int64 `json:"pageviews"`
// Visitors is how many distinct people they came from.
Visitors int64 `json:"visitors"`
// Pct is this bucket's share of ALL in-window pageviews, 0..100, one decimal —
// not of the returned rows, so a top-N shows the long tail honestly.
Pct float64 `json:"pct"`
}
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 ¶
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 event.event, 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 plane 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 ¶
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 ¶
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 ¶
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"`
// GroupType names WHICH grouping the id belongs to (organization, workspace,
// account). It is a map key rather than a column so the second grouping is a data
// change: the plane stores `groups[type] = id`, never group0..group4, because a
// fifth positional slot is a sixth one waiting to become a version suffix.
// Absent means the only grouping anything sends today, `organization`.
GroupType string `json:"groupType"`
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)
// THE OTEL SIGNALS. One envelope carries all four — event, log, span, metric —
// because they differ in their BODY, not in who sent them or when. Splitting the
// envelope per signal duplicates org, time, session and identity four ways and
// lets them drift; the fact plane (fact.go) reads one shape and writes one row.
//
// Each is a pointer so absent is distinct from empty: a pageview carries no Span,
// and a zero-valued one would be a span of duration 0 rather than no span at all.
Log *LogBody `json:"log"`
Span *SpanBody `json:"span"`
Metric *MetricBody `json:"metric"`
Clip *ClipBody `json:"clip"`
// Kind narrows Type when the surface knows more than the wire word does — a
// span's client/server role, a page's navigation kind. Empty means the route's
// own default, which is what every existing client sends.
Kind string `json:"kind"`
// SITE is the deployed property a signal came from, and it is carried on EVERY
// event, not only on a failure.
//
// It lived on the exception alone, so sentry.hanzo.ai could group faults by site
// while analytics.hanzo.ai — reading the event stream — had no site column at
// all, and "all sites" was a question the data could not answer. One property
// per row is what makes the two surfaces read the same world.
Site string `json:"site"`
// Level, Release, Environment and Service qualify a signal the same way for
// every kind: which severity, which build, which deployment, which service. They
// are not error-only either, for the same reason Site is not.
Level string `json:"level"`
Release string `json:"release"`
Environment string `json:"environment"`
Service string `json:"service"`
// TraceID and SpanID correlate a signal to a trace whatever its body is, so a log
// and the span it was emitted inside join without either owning the other.
TraceID string `json:"traceId"`
SpanID string `json:"spanId"`
// Resource is the fingerprint of the emitting resource — the host, pod and
// service attributes a collector already deduplicates into its own dimension
// table. It joins event.log_resource / event.span_resource on `fingerprint`.
// ONE name for it: the plane previously carried this fact as `resource UInt64`
// AND `resource_fingerprint String` on the same row, one of them always zero.
Resource string `json:"resource"`
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 ¶
CaptureResult is the honest receipt: persisted vs dropped (unroutable) counts.
type ClipBody ¶ added in v1.801.381
type ClipBody struct {
// Object is the blob's address in object storage.
Object string `json:"object"`
// Bytes is its size, so a session list can show weight without opening it.
Bytes uint64 `json:"bytes"`
// Duration is the wall time the clip covers, in nanoseconds — the same column a
// span's elapsed time uses, because it is the same fact.
Duration uint64 `json:"duration"`
}
ClipBody is the body of a session-replay clip: WHERE THE BLOB IS, not the blob.
A clip's recording is a multi-megabyte time-ordered binary. It is wrong for a bus message (which is a hand-off, sized for many small facts) and wrong for a warehouse row (which is a column store optimized for scanning narrow values), so it goes neither place. It is written to object storage by whoever recorded it, and the fact plane carries the INDEX: the address, the size and the span of time it covers.
That is what makes a whole product cost two columns. Everything else a replay needs — which session, which org, which page, when — is already the envelope.
type CommerceOverview ¶
type CommerceOverview struct {
// Available is false when the product-event table could not be read — the lens is
// reported missing rather than as zeros that look like no sales.
Available bool `json:"available"`
// Reason says why the lens is unavailable. Omitted when it is available.
Reason string `json:"reason,omitempty"`
// Orders is how many order_completed events landed in the window.
Orders int64 `json:"orders"`
// Revenue is the total those orders carried, in the events' own currency unit.
Revenue float64 `json:"revenue"`
// AOV is average order value — Revenue/Orders, rounded to two places. Zero when
// there were no orders.
AOV float64 `json:"aov"`
// Source is the warehouse table the lens read.
Source string `json:"source"`
}
CommerceOverview is the commerce lens over event.fact. Honest-empty until commerce emits order events.
type Event ¶
type Event struct {
Event string `json:"event"` // event name (required; empty ⇒ dropped as unroutable)
Type string `json:"type"` // canonical kind: pageview | error | identify | group | event (default)
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 five 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 EventEnvelope ¶ added in v1.801.350
type EventEnvelope struct {
Org string `json:"org"`
ID string `json:"id"`
Name string `json:"name"`
DistinctID string `json:"distinct_id,omitempty"`
AnonymousID string `json:"anonymous_id,omitempty"`
Time time.Time `json:"time"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
Referrer string `json:"referrer,omitempty"`
Revenue float64 `json:"revenue,omitempty"`
Currency string `json:"currency,omitempty"`
ProductID string `json:"product_id,omitempty"`
Quantity uint32 `json:"quantity,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
}
EventEnvelope is what an ACCEPTED PRODUCT EVENT looks like on the plane: the subscriber-facing projection of a SinkEvent, carrying the commerce and identity fields a downstream integration converts on.
Org is FIRST and it is the tenant, spelled EventOrgKey — the delivery engine (apps/webhooks) resolves the subscriber's org from it, and an envelope without one is delivered to nobody. It is stamped from the SERVER-resolved tenant, never from the wire, on the same terms as fact.org.
It is a SEPARATE type from message, and deliberately: message is the warehouse's contract (its field names are the column names), this is the SUBSCRIBER's contract, and folding them together would make a webhook payload change every time a table gains a column. What they share is the one thing that must be shared — the tenant key.
type Exception ¶
type Exception struct {
Type string `json:"type,omitempty"`
Message string `json:"message"`
Stack string `json:"stack,omitempty"`
Handled *bool `json:"handled,omitempty"`
// Frames is the STRUCTURED stack, when the SDK sent one. Stack stays as the raw
// text a client without a parser sends, so neither is derived from the other and
// a client may send either or both.
//
// It is what lets a fault be grouped and rendered by function, file and line
// rather than by a string compare over a whole trace — the raw text cannot
// answer "is this frame ours" (framesOf marks own) and cannot be scrubbed field
// by field.
Frames []Frame `json:"frames,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 Frame ¶ added in v1.801.350
type Frame struct {
// Function is the called function's name.
Function string `json:"function,omitempty"`
// File is the source file. It is treated as a URL and scrubbed, because a
// bundler emits one with a query string that can carry a token.
File string `json:"file,omitempty"`
// Line is the 1-based line number.
Line uint32 `json:"line,omitempty"`
// Column is the 1-based column number.
Column uint32 `json:"column,omitempty"`
}
Frame is one call site in a structured stack. Line and Column are unsigned because a position is never negative and the fact plane stores them that way.
type KeyResolver ¶ added in v1.801.437
KeyResolver maps a publishable ingest key to the scope it names.
found=false ⇒ no project holds this key: the honest refusal, and the whole of "if the site is missing it stops recording". err ⇒ a real store or transport failure, which is NOT a refusal and must not be collapsed into one — a transient failure of the owning app would otherwise read exactly like every customer's site being deleted at once.
type LLMOverview ¶
type LLMOverview struct {
// Available is true whenever the ledger answered — including with no usage in the
// window, which is honest zeros rather than a missing lens.
Available bool `json:"available"`
// Requests is how many LLM calls the org made in the window.
Requests int64 `json:"requests"`
// Tokens is prompt plus completion tokens over those calls.
Tokens int64 `json:"tokens"`
// PromptTokens is the input half of Tokens.
PromptTokens int64 `json:"promptTokens"`
// CompletionTokens is the output half of Tokens.
CompletionTokens int64 `json:"completionTokens"`
// SpendCents is what those calls cost, in cents.
SpendCents int64 `json:"spendCents"`
// Models is how many distinct models the org called.
Models int64 `json:"models"`
// Providers is how many distinct providers served them.
Providers int64 `json:"providers"`
// Errors is how many of Requests failed.
Errors int64 `json:"errors"`
// ErrorRate is Errors/Requests, 0..1, rounded to three places. Zero when there
// were no requests.
ErrorRate float64 `json:"errorRate"`
// Source is the warehouse table the lens read.
Source string `json:"source"`
}
LLMOverview is the flagship lens: real per-org KPIs from hanzo.cloud_usage.
type LogBody ¶ added in v1.801.350
type LogBody struct {
// Severity is the OTel severity text, lowercased on the way into a fact.
Severity string `json:"severity"`
// Number is the OTel severity NUMBER (1..24), which orders severities without
// parsing their text and survives a client that spells one differently.
Number uint8 `json:"number"`
// Body is the log message. It is scrubbed where it enters a fact, not here, so
// there is one scrub on one path.
Body string `json:"body"`
}
LogBody is the body of a log signal: OTel severity, its numeric rank, and the message. The body is scrubbed at the one point it enters a fact, never here.
type MetricBody ¶ added in v1.801.350
type MetricBody struct {
// Name is what was measured, and it is also the fact's event name for a metric.
Name string `json:"name"`
// Value is the sample.
Value float64 `json:"value"`
// Labels are the dimensions the sample is sliced by.
Labels map[string]any `json:"labels"`
}
MetricBody is the body of a metric sample: what was measured, its value, and the labels it is sliced by.
type ModelRow ¶
type ModelRow struct {
// Model is the model id, e.g. zen5-coder.
Model string `json:"model"`
// Provider is who served it.
Provider string `json:"provider"`
// Requests is how many calls went to this model.
Requests int64 `json:"requests"`
// Tokens is prompt plus completion tokens over those calls.
Tokens int64 `json:"tokens"`
// SpendCents is what they cost, in cents.
SpendCents int64 `json:"spendCents"`
// Pct is this model's share of the window's returned spend, 0..100, one decimal.
Pct float64 `json:"pct"`
}
ModelRow is one model's usage in the window, ranked by spend.
type Overview ¶
type Overview struct {
// Range is the window that was actually applied: 24h, 7d, 30d or custom.
Range string `json:"range"`
// Start is the window's inclusive lower bound, RFC3339 UTC.
Start string `json:"start"`
// End is the window's exclusive upper bound, RFC3339 UTC.
End string `json:"end"`
// Interval is the bucket width the window implies: hour or day.
Interval types.Interval `json:"interval"`
// Scope names the tenant these numbers belong to.
Scope Scope `json:"scope"`
// LLM is the LLM usage lens — real per-org data.
LLM LLMOverview `json:"llm"`
// Web is the web-traffic lens over product events.
Web WebOverview `json:"web"`
// Commerce is the orders/revenue lens over product events.
Commerce CommerceOverview `json:"commerce"`
}
Overview is one window's KPIs across all three lenses — the console's landing view.
type ProductRow ¶
type ProductRow struct {
// ProductID is the product the order events named.
ProductID string `json:"productId"`
// Orders is how many order_completed events carried it.
Orders int64 `json:"orders"`
// Revenue is the total they carried, in the events' own currency unit.
Revenue float64 `json:"revenue"`
// Units is the summed quantity sold.
Units int64 `json:"units"`
}
ProductRow is one product's commerce result in the window, ranked by revenue.
type Scope ¶
type Scope struct {
// Org is the IAM org slug the rows were read under: the validated principal's,
// resolved server-side.
Org string `json:"org"`
}
Scope names WHOSE data a lens answered with — the tenant the server resolved, so a reader can see the answer is its own org's and not a parameter it passed.
type SinkEvent ¶
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 SpanBody ¶ added in v1.801.350
type SpanBody struct {
// ID is this span's own id, which a client emitting a span knows precisely.
ID string `json:"id"`
// Trace is the trace this span belongs to.
Trace string `json:"trace"`
// Parent is the enclosing span's id, empty for a root span.
Parent string `json:"parent"`
// Kind is the span's role — client, server, producer, consumer, internal.
Kind string `json:"kind"`
// Status is how the span ended: ok, error, or unset.
Status string `json:"status"`
// Duration is the elapsed time in nanoseconds. Unsigned because a span cannot
// take negative time.
Duration uint64 `json:"duration"`
}
SpanBody is the body of a span: its place in the trace and how it ended. ID and Trace are carried here as well as on the envelope because a client that sends a span knows them precisely, while a log only correlates.
type SubjectOutcome ¶
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 ¶
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 event.fact. It is the measurement seam the experiments primitive composes: flags assignment joins to these outcomes by distinct_id. The plane is never created here — its DDL owner is hanzoai/o11y — so a missing table surfaces as the query's own error.
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 is the window that was actually applied: 24h, 7d, 30d or custom.
Range string `json:"range"`
// Start is the window's inclusive lower bound, RFC3339 UTC.
Start string `json:"start"`
// End is the window's exclusive upper bound, RFC3339 UTC.
End string `json:"end"`
// Interval is the bucket width: hour or day.
Interval types.Interval `json:"interval"`
// Scope names the tenant these numbers belong to.
Scope Scope `json:"scope"`
// Series is one point per bucket, oldest first, with empty buckets zero-filled.
Series []UsagePoint `json:"series"`
// Source is the warehouse table the series read.
Source string `json:"source"`
}
Timeseries is the LLM usage series over one window, gap-filled so a client charts a continuous line.
type Top ¶
type Top struct {
// Range is the window that was actually applied: 24h, 7d, 30d or custom.
Range string `json:"range"`
// Start is the window's inclusive lower bound, RFC3339 UTC.
Start string `json:"start"`
// End is the window's exclusive upper bound, RFC3339 UTC.
End string `json:"end"`
// Scope names the tenant these rankings belong to.
Scope Scope `json:"scope"`
// Models ranks the window's LLM models by spend — real per-org data.
Models TopModels `json:"models"`
// Products ranks the window's products by revenue.
Products TopProducts `json:"products"`
// Pages ranks the paths visitors requested, by pageviews.
Pages Breakdown `json:"topPages"`
// Referrers ranks the external domains visitors arrived from, by pageviews.
Referrers Breakdown `json:"topReferrers"`
// Sources ranks the utm_source campaigns visitors arrived on, by pageviews.
Sources Breakdown `json:"topSources"`
}
Top is one window's five ranked lenses — the console's "what is driving this" view.
type TopModels ¶
type TopModels struct {
// Available is true whenever the ledger answered, including with no rows.
Available bool `json:"available"`
// Items is the ranked models, highest spend first.
Items []ModelRow `json:"items"`
// Source is the warehouse table the lens read.
Source string `json:"source"`
}
TopModels is the models lens: the window's models ranked by spend, then requests.
type TopProducts ¶
type TopProducts struct {
// Available is false when the product-event table could not be read.
Available bool `json:"available"`
// Reason says why the lens is unavailable. Omitted when it is available.
Reason string `json:"reason,omitempty"`
// Items is the ranked products, highest revenue first. Empty rather than absent.
Items []ProductRow `json:"items"`
// Source is the warehouse table the lens read.
Source string `json:"source"`
}
TopProducts is the products lens over product events. Honest-empty until commerce emits order events.
type UTM ¶
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 UsagePoint ¶ added in v1.801.350
type UsagePoint struct {
// T is the bucket's start, RFC3339 UTC, aligned to the interval.
T string `json:"t"`
// Requests is how many LLM calls fell in this bucket.
Requests int64 `json:"requests"`
// Tokens is prompt plus completion tokens over those calls.
Tokens int64 `json:"tokens"`
// SpendCents is what they cost, in cents.
SpendCents int64 `json:"spendCents"`
}
UsagePoint is one bucket of the LLM usage series. Named for the value rather than for its shape: the OpenAPI schema namespace is FLAT across the whole fleet, and `SeriesPoint` is already the admin launch board's {t, value} pair — one name with two shapes is what the fleet weave refuses, since every generated SDK would bind whichever it read last.
type WebOverview ¶
type WebOverview struct {
// Available is false when the product-event table could not be read — the lens is
// reported missing rather than as zeros that look like real traffic.
Available bool `json:"available"`
// Reason says why the lens is unavailable. Omitted when it is available.
Reason string `json:"reason,omitempty"`
// Pageviews is how many $pageview events landed in the window.
Pageviews int64 `json:"pageviews"`
// Visitors is how many distinct people those pageviews came from.
Visitors int64 `json:"visitors"`
// Sessions is how many distinct visits they span.
Sessions int64 `json:"sessions"`
// Source is the warehouse table the lens read.
Source string `json:"source"`
}
WebOverview is the web lens over event.fact. Honest-empty (Available=false) until the collector emits web events.