diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab12c6..150ba34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [v0.2.3] - 2026-06-17 + +Metric kind moves from a positional argument to a declaration OPTION, and a module may now override the **customer** price of a platform-infra metric. The runtime emit (`ms.Record`) and the declaration-first contract are unchanged; only the `ms.Meter` shape and the reserved-namespace rules change. + +### Changed (BREAKING) +- **`ms.Meter` kind is now an OPTION, not a positional argument.** The signature is `ms.Meter(name string, opts ...ms.MetricOption)`. `ms.Counter` and `ms.Gauge` are now `ms.MetricOption`s (functional options that set the kind) rather than `ms.Kind` values, so a call reads the same — `ms.Meter("orders.placed", ms.Counter, ms.Unit("order"), ms.Price(50_000))` — but the kind is supplied positionally no longer. A **custom** (non-reserved) metric MUST pass exactly one kind option: `ms.Meter` panics if no kind is given or if both `ms.Counter` and `ms.Gauge` are passed. The exported `ms.Kind` type and the `ms.Counter`/`ms.Gauge` `Kind` constants are gone (the kind enum is now internal to the manifest/registry). + +### Added +- **Platform-infra customer-price override.** A reserved `infra.*` / `platform.*` metric — previously rejected outright at declaration — may now be DECLARED with `ms.Price` **only**, to override what the module's customer is billed for that platform-measured infra (e.g. `ms.Meter("infra.compute.ms", ms.Price(0))` to absorb platform compute into the module's own pricing). This is a pure customer-facing (Plane-2) choice: the developer still owes the platform the measured COGS regardless. Passing a kind (`ms.Counter`/`ms.Gauge`) or `ms.Unit` on a reserved name panics — kind/unit are platform-owned. The manifest entry for such an override carries the price only (no kind/unit; the platform catalog supplies them). +- **`ms.Record` rejects a reserved name.** A module can declare a reserved `infra.*`/`platform.*` price-override but can never self-report its value: `ms.Record(ctx, "infra.compute.ms", …)` returns an error. The platform meters its own infra at its own chokepoint; an SDK-reported quantity for a reserved metric is never billable. + ## [v0.2.2] - 2026-06-16 Declaration-first usage metering. A module DECLARES each metric once, up front, with its kind + unit + price (`ms.Meter`), then emits at runtime **by name** with a single `ms.Record` — exactly mirroring the `ms.Emits` (declare) / `ms.Emit` (emit by name) pair. There is no stored handle. The declaration flows into the manifest, so the platform's metric catalog is authoritative — a call site can never mislabel a metric's kind, and billing can populate its catalog before any event arrives. diff --git a/VERSION b/VERSION index ee1372d..7179039 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 +0.2.3 diff --git a/docs/api-reference.md b/docs/api-reference.md index d7086c5..ea010b7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -117,12 +117,13 @@ ms.Tx(ctx, func(q db.Querier) error { |---|---| | `ms.Cache(ctx)` | Per-app Redis client. | | `ms.Storage(ctx)` | Per-app object storage. S3 as origin + presigned multipart upload; reads served from R2 via a Cloudflare Worker cache layer. | -| `ms.Meter(name, kind, opts...)` | DECLARE a usage metric once in setup (kind `ms.Counter`/`ms.Gauge`; `ms.Unit`/`ms.Price` options). Registers it into the manifest; returns nothing. | -| `ms.Record(ctx, name, value)` | Emit a usage event BY NAME for a declared metric. Mirrors `ms.Emits`/`ms.Emit`; errors on an undeclared name. | +| `ms.Meter(name, opts...)` | DECLARE a usage metric once in setup. The kind is an OPTION (`ms.Counter`/`ms.Gauge`); also `ms.Unit`/`ms.Price`. A custom metric MUST pass exactly one kind. A reserved `infra.*`/`platform.*` metric may pass `ms.Price` ONLY (a customer-passthrough override; kind/unit are platform-owned). Registers it into the manifest; returns nothing. | +| `ms.Record(ctx, name, value)` | Emit a usage event BY NAME for a declared metric. Mirrors `ms.Emits`/`ms.Emit`; errors on an undeclared name and on a reserved `infra.*`/`platform.*` name (platform-measured, never self-reported). | ```go // setup ms.Meter("transcode.minutes", ms.Counter, ms.Unit("minute"), ms.Price(50_000)) +ms.Meter("infra.compute.ms", ms.Price(0)) // reserved: absorb platform compute (price-only override) // handler ms.Record(r.Context(), "transcode.minutes", 12) ``` diff --git a/examples/template/meter.go b/examples/template/meter.go index 043fa25..72479cd 100644 --- a/examples/template/meter.go +++ b/examples/template/meter.go @@ -14,10 +14,11 @@ package main // external store size, active rows). Billing errors are logged but must not fail // the handler. // -// Gauge metric names must be module-owned (e.g. "myapp.objects.bytes"). Do NOT -// declare a platform-billable infra metric like "storage.bytes" — the platform -// measures its own infra and reserves the infra.*/platform.* namespace (ms.Meter -// panics on a reserved prefix). +// Gauge metric names must be module-owned (e.g. "myapp.objects.bytes"). The +// platform measures its own infra and reserves the infra.*/platform.* namespace: +// you may declare a reserved metric with ms.Price ONLY to override what your +// customer is billed for that infra (passing a kind or unit on it panics), but +// you can never self-report its value via ms.Record — the platform meters it. import ( "log" @@ -46,6 +47,15 @@ func registerMeter() { // produce large invoices, so pick the cadence + price deliberately. ms.Meter("myapp.objects.bytes", ms.Gauge, ms.Unit("byte"), ms.Price(1)) + // Reserved infra metric: PRICE-OVERRIDE only. Here we set the per-unit + // customer passthrough for platform compute to 0 — absorbing platform + // compute into our own pricing (we still owe the platform the measured COGS + // regardless; this only changes what OUR customer is billed). kind/unit are + // platform-owned, so we pass ms.Price alone — adding ms.Counter/ms.Gauge or + // ms.Unit here would panic, and ms.Record("infra.compute.ms", ...) is + // rejected (the platform meters compute at its own chokepoint). + ms.Meter("infra.compute.ms", ms.Price(0)) + ms.Platform(func(r chi.Router) { r.Post("/orders", func(w http.ResponseWriter, r *http.Request) { // ... place the order ... diff --git a/internal/core/meter_test.go b/internal/core/meter_test.go index c3fec77..629aad3 100644 --- a/internal/core/meter_test.go +++ b/internal/core/meter_test.go @@ -78,15 +78,53 @@ func TestMeter_PanicsOnDuplicateName(t *testing.T) { }) } -func TestMeter_PanicsOnReservedPrefix(t *testing.T) { +func TestMeter_PanicsOnReservedPrefixWithKind(t *testing.T) { m, _ := New(Config{ID: "media"}) for _, name := range []string{"infra.compute.ms", "platform.storage.bytes"} { - assertPanics(t, "expected panic on reserved-prefix Meter "+name, func() { + assertPanics(t, "expected panic on reserved-prefix Meter with a kind "+name, func() { m.Meter(name, meter.Counter) }) } } +// TestMeter_ReservedPriceOverrideInManifest asserts a reserved infra.* +// price-override (Price only) is accepted and surfaces in the manifest as a +// price-only entry: NO kind/unit (platform-owned), price carried. +func TestMeter_ReservedPriceOverrideInManifest(t *testing.T) { + m := newTestModuleWithSecret(t, "media") + m.Meter("infra.compute.ms", meter.Price(0)) // absorb platform compute + + rec := doRequestWithSecret(t, m.Router(), "GET", "/__mirrorstack/platform/manifest", "secret") + var got system.ManifestPayload + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if len(got.Metrics) != 1 { + t.Fatalf("metrics = %d, want 1: %+v", len(got.Metrics), got.Metrics) + } + d := got.Metrics[0] + if d.Name != "infra.compute.ms" { + t.Errorf("name = %q, want infra.compute.ms", d.Name) + } + if d.Kind != "" || d.Unit != "" { + t.Errorf("reserved override carried kind=%q unit=%q, want both empty (platform-owned)", d.Kind, d.Unit) + } + if d.Price == nil || *d.Price != 0 { + t.Errorf("price = %v, want explicit 0", d.Price) + } +} + +// TestRecord_RejectsReservedName asserts a reserved metric — even when declared +// as a price-override — can never be self-reported via ms.Record. +func TestRecord_RejectsReservedName(t *testing.T) { + m, _ := New(Config{ID: "media"}) + m.Meter("infra.compute.ms", meter.Price(0)) + + if err := m.Record(context.Background(), "infra.compute.ms", 1); err == nil { + t.Error("expected an error recording a reserved platform-measured metric") + } +} + func TestMeter_TopLevelPanicsBeforeInit(t *testing.T) { resetDefault(t) assertPanics(t, "expected panic for top-level Meter before Init", func() { diff --git a/internal/core/resources.go b/internal/core/resources.go index 0127886..b03f713 100644 --- a/internal/core/resources.go +++ b/internal/core/resources.go @@ -76,30 +76,34 @@ func (m *Module) resolveStorage(ctx context.Context) (*storage.Client, error) { // Meter DECLARES a usage metric. Call it once, up front (startup code), per // metric — exactly like Emits / RegisterPermission: it registers the metric as -// a SIDE EFFECT and returns NOTHING. The declaration (name + kind + unit + -// price) is recorded in the manifest so the platform's metric catalog is -// authoritative before any event arrives, AND in the meter client's by-name -// registry so Record can resolve the metric. +// a SIDE EFFECT and returns NOTHING. The declaration (kind + unit + price) is +// recorded in the manifest so the platform's metric catalog is authoritative +// before any event arrives, AND in the meter client's by-name registry so +// Record can resolve the metric. // -// kind is meter.Counter (additive; the platform SUMs) or meter.Gauge (absolute -// level; the platform takes MAX or a time-weighted integral, never a SUM). -// Options set the unit and the per-unit customer price (meter.Unit/meter.Price, -// both optional). +// The kind is an OPTION: meter.Counter (additive; the platform SUMs) or +// meter.Gauge (absolute level; the platform takes MAX or a time-weighted +// integral, never a SUM). meter.Unit / meter.Price set the unit and the +// per-unit customer price (both optional). // // Emit at runtime with the package-level Record(ctx, name, value) — BY NAME, -// mirroring Emits/Emit. Panics on a duplicate metric name (a second -// declaration would silently disagree on kind/price), an invalid name, an -// unknown kind, or a reserved infra.*/platform.* prefix. Like the other Module -// resource methods, it requires New() to have returned successfully — calling -// it on a zero Module panics on the nil meterClient (the package-level -// wrapper's mustDefault guards the before-Init case). +// mirroring Emits/Emit. A custom metric MUST pass exactly one kind option; a +// reserved infra.*/platform.* metric may carry meter.Price ONLY (kind/unit are +// platform-owned). Panics on a duplicate name, an invalid name, conflicting +// kinds, a missing kind on a custom metric, or a kind/unit on a reserved name. +// Like the other Module resource methods, it requires New() to have returned +// successfully — calling it on a zero Module panics on the nil meterClient (the +// package-level wrapper's mustDefault guards the before-Init case). // // ms.Meter("orders.placed", ms.Counter, ms.Unit("order"), ms.Price(50_000)) -func (m *Module) Meter(name string, kind meter.Kind, opts ...meter.MetricOption) { - d := meter.DeclFromOptions(name, kind, opts...) - // Declare validates name/kind/reserved-prefix and registers into the meter - // client's by-name registry (panics on a duplicate name there). +// ms.Meter("infra.compute.ms", ms.Price(0)) // reserved price-override +func (m *Module) Meter(name string, opts ...meter.MetricOption) { + d := meter.DeclFromOptions(name, opts...) + // Declare validates the name + custom/reserved option rules and registers + // into the meter client's by-name registry (panics on a duplicate name). m.meterClient.Declare(m.config.ID, d) + // For a reserved price-override d.Kind is empty (the platform catalog + // supplies the kind/unit); the manifest entry then carries price only. decl := registry.MetricDecl{Name: d.Name, Kind: string(d.Kind), Unit: d.Unit} if d.PriceSet { p := d.Price @@ -131,8 +135,8 @@ func Storage(ctx context.Context) (storage.Storer, error) { // Meter declares a usage metric on the default module (side effect, no return). // Panics before Init. -func Meter(name string, kind meter.Kind, opts ...meter.MetricOption) { - mustDefault("Meter").Meter(name, kind, opts...) +func Meter(name string, opts ...meter.MetricOption) { + mustDefault("Meter").Meter(name, opts...) } // Record emits a usage event by name on the default module. Panics before Init. diff --git a/internal/registry/registry.go b/internal/registry/registry.go index c4736dc..febd497 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -127,13 +127,16 @@ type OutboundContribution struct { // is authoritative for how a metric aggregates and is priced. // // Kind is "counter" (additive; SUM) or "gauge" (absolute level; MAX/integral). +// For a custom metric Kind is always set; for a reserved infra.*/platform.* +// PRICE-OVERRIDE entry Kind and Unit are EMPTY (omitempty) — the platform +// catalog supplies the kind/unit, the module only overrides the customer price. // Unit is a display unit (e.g. "order", "byte"); empty when undeclared. // Price is the per-unit CUSTOMER price in micro-dollars and is omitempty: a // metric may be metered without a declared price (PriceSet distinguishes a // declared 0 from "no price", carried via the pointer being non-nil). type MetricDecl struct { Name string `json:"name"` - Kind string `json:"kind"` + Kind string `json:"kind,omitempty"` Unit string `json:"unit,omitempty"` Price *int64 `json:"price,omitempty"` } diff --git a/meter/meter.go b/meter/meter.go index 49cc037..ab33aa4 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -52,38 +52,98 @@ var reservedPrefixes = []string{"infra.", "platform."} // declaration and recorded in the manifest; it does NOT travel on the wire // (the platform's manifest-fed catalog is authoritative). A single Record // call emits for both kinds — the kind decides how the platform aggregates. +// +// Kind is set via the Counter / Gauge declaration OPTIONS, not as a positional +// argument; counterKind / gaugeKind are the internal enum values those options +// carry into the manifest and registry. type Kind string const ( - // Counter is an additive / one-time value the platform SUMs over the - // billing period. Use it for things you count: orders placed, minutes - // transcoded, messages sent. - Counter Kind = "counter" - // Gauge is an absolute current level the platform never SUMs — it takes - // the MAX or a time-weighted integral. Use it for levels you can safely - // re-report on a heartbeat: stored bytes, active rows, open connections. - // Gauge is self-healing: a lost sample only loses resolution. - Gauge Kind = "gauge" + // counterKind is an additive / one-time value the platform SUMs over the + // billing period (orders placed, minutes transcoded, messages sent). Set + // via the Counter option. + counterKind Kind = "counter" + // gaugeKind is an absolute current level the platform never SUMs — it + // takes the MAX or a time-weighted integral (stored bytes, active rows, + // open connections). Set via the Gauge option. + gaugeKind Kind = "gauge" ) // IsValid reports whether k is one of the two known kinds. -func (k Kind) IsValid() bool { return k == Counter || k == Gauge } +func (k Kind) IsValid() bool { return k == counterKind || k == gaugeKind } -// MetricOption configures a metric at declaration time. The only options today -// are Unit and Price; both are optional (a metric may be metered without a -// declared unit or price). -type MetricOption func(*metricOptions) +// MetricOption configures a metric at declaration time. The KIND is itself an +// option (Counter / Gauge), alongside Unit and Price. A custom metric MUST pass +// exactly one kind option; a reserved infra.*/platform.* metric may pass ONLY +// Price (its kind/unit are platform-owned). All other combinations panic at +// declaration (see Declare). +// +// MetricOption is an INTERFACE, not a func type, on purpose: it lets Counter and +// Gauge be untyped-style package CONSTANTS rather than reassignable package +// vars. The SDK is a security boundary — if Counter/Gauge were exported `var`s a +// hostile module could execute `ms.Counter = nil` and silently break every +// later Meter call. As const-backed interface values they cannot be reassigned +// from outside (or inside) the package, while the call site stays parens-free +// (`ms.Meter(name, ms.Counter, ...)`). +type MetricOption interface { + applyMetric(*metricOptions) +} type metricOptions struct { + kind Kind + kindSet bool + kindDup bool // a second, conflicting kind option was passed unit string + unitSet bool price int64 priceSet bool } +// Counter / Gauge are CONSTANTS, not vars, so a third-party module cannot +// reassign them (the SDK is a trust boundary). A const of a defined type is +// immutable from any package; reassigning `ms.Counter = …` is a compile error. +const ( + // Counter declares the metric as additive: the platform SUMs it over the + // billing period. Use it for things you count: orders placed, minutes + // transcoded, messages sent. Pass it to Meter as the kind option. + Counter kindOption = kindOption(counterKind) + // Gauge declares the metric as an absolute current level the platform + // never SUMs — it takes the MAX or a time-weighted integral. Use it for + // levels you can safely re-report on a heartbeat: stored bytes, active + // rows, open connections. Gauge is self-healing: a lost sample only loses + // resolution. Pass it to Meter as the kind option. + Gauge kindOption = kindOption(gaugeKind) +) + +// kindOption is the immutable MetricOption type backing the Counter / Gauge +// CONSTANTS. Being a defined type over Kind, its values are const-able (so they +// cannot be reassigned), unlike a func-typed option. +type kindOption Kind + +// applyMetric records the kind on the accumulating options. If a kind was +// already set to a different value, it records the conflict so Declare can panic +// (a metric cannot be both a counter and a gauge). +func (k kindOption) applyMetric(o *metricOptions) { + if o.kindSet && o.kind != Kind(k) { + o.kindDup = true + } + o.kind = Kind(k) + o.kindSet = true +} + +// metricOptionFunc adapts a closure to MetricOption for the non-kind options +// (Unit / Price), which carry runtime values and so cannot be constants. +type metricOptionFunc func(*metricOptions) + +func (f metricOptionFunc) applyMetric(o *metricOptions) { f(o) } + // Unit sets the metric's display unit (e.g. "order", "byte"). Informational // metadata for the platform UI / invoice line; it does not affect aggregation. func Unit(u string) MetricOption { - return func(o *metricOptions) { o.unit = u } + return metricOptionFunc(func(o *metricOptions) { + o.unit = u + o.unitSet = true + }) } // Price sets the metric's per-unit CUSTOMER price in micro-dollars (1e-6 USD). @@ -92,43 +152,85 @@ func Unit(u string) MetricOption { // only to platform-infra metrics, never a module's custom metric). Optional — // omit it to meter without charging. func Price(microDollars int64) MetricOption { - return func(o *metricOptions) { + return metricOptionFunc(func(o *metricOptions) { o.price = microDollars o.priceSet = true - } + }) } // DeclFromOptions applies the variadic options to produce a Decl. Used by core -// (ms.Meter) to translate the public name/kind/options into the declaration -// that is both validated + registered (Declare) and registered into the -// manifest (registry.AddMetric). -func DeclFromOptions(name string, kind Kind, opts ...MetricOption) Decl { +// (ms.Meter) to translate the public name + options into the declaration that +// is both validated + registered (Declare) and registered into the manifest +// (registry.AddMetric). The kind is itself an option (Counter / Gauge); the +// resulting Decl records which option groups were set so Declare can enforce +// the custom-vs-reserved rules (custom requires a kind; reserved allows price +// only). +func DeclFromOptions(name string, opts ...MetricOption) Decl { o := &metricOptions{} for _, opt := range opts { if opt != nil { - opt(o) + opt.applyMetric(o) } } - return Decl{Name: name, Kind: kind, Unit: o.unit, Price: o.price, PriceSet: o.priceSet} + return Decl{ + Name: name, + Kind: o.kind, + KindSet: o.kindSet, + kindDup: o.kindDup, + Unit: o.unit, + UnitSet: o.unitSet, + Price: o.price, + PriceSet: o.priceSet, + } } // Decl is the declared shape of a metric: name + kind + unit + optional price. // It is what flows into the manifest (see registry.MetricDecl), so the platform // can populate its metric_definitions catalog at install/publish. +// +// KindSet / UnitSet / PriceSet report whether each option group was supplied, +// so Declare can enforce that a custom metric carries a kind and a reserved +// infra.*/platform.* metric carries price only. For a reserved price-override +// declaration Kind is empty (KindSet false) and the platform catalog supplies +// the kind/unit. +// +// Construct a Decl via DeclFromOptions, NOT a struct literal: it has an +// unexported conflict-tracking field (kindDup) that a literal cannot set, so a +// hand-built Decl would silently skip the conflicting-kind guard. type Decl struct { Name string Kind Kind + KindSet bool + kindDup bool // both Counter and Gauge were passed (conflicting kinds) Unit string + UnitSet bool Price int64 PriceSet bool } +// IsReserved reports whether name falls under a platform-owned namespace +// (infra.*/platform.*). A reserved metric is platform-measured: a module may +// declare it with Price ONLY (to override the customer passthrough) but may +// never set its kind/unit or self-report its value. +func IsReserved(name string) bool { + for _, p := range reservedPrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + // ValidateMetricName rejects names that are empty, contain a path separator, -// whitespace, a dot-segment, or a null byte, or that fall under a reserved -// platform namespace. It mirrors registry.ValidateName plus the reserved-prefix -// rule specific to metrics. The mirroring is deliberate, NOT accidental -// duplication: meter is a public package and must not import internal/registry, -// so the shared rule is restated here rather than shared via an internal import. +// whitespace, a dot-segment, or a null byte. It mirrors registry.ValidateName. +// The mirroring is deliberate, NOT accidental duplication: meter is a public +// package and must not import internal/registry, so the shared rule is restated +// here rather than shared via an internal import. +// +// The reserved infra.*/platform.* namespace is NOT rejected here: a module may +// declare such a name with Price ONLY to override the customer passthrough. The +// reserved-vs-custom option rules are enforced in Declare; self-reporting a +// reserved name is rejected in Record. func ValidateMetricName(name string) { if name == "" { panic("mirrorstack/meter: Meter name cannot be empty") @@ -139,11 +241,6 @@ func ValidateMetricName(name string) { if strings.Contains(name, "..") { panic("mirrorstack/meter: Meter(" + name + ") contains '..'") } - for _, p := range reservedPrefixes { - if strings.HasPrefix(name, p) { - panic("mirrorstack/meter: Meter(" + name + ") uses reserved platform prefix " + p + " (infra.*/platform.* are platform-measured, not module-declarable)") - } - } } // Declare validates a metric declaration and registers it into the client's @@ -156,14 +253,47 @@ func ValidateMetricName(name string) { // so the public ms.Meter contract is enforced in one place, plus its own // registry duplicate guard. // -// Panics on an empty/malformed name, an unknown kind, a reserved -// infra.*/platform.* prefix, or a duplicate metric name — declaration is -// startup code, so a bad declaration is a programmer error that must fail -// loudly (a second declaration would silently disagree on kind/price). +// Validation (all panic — declaration is startup code, so a bad declaration is +// a programmer error that must fail loudly): +// - empty/malformed name; +// - conflicting kinds (both Counter and Gauge passed); +// - a CUSTOM (non-reserved) name without exactly one kind option; +// - a RESERVED infra.*/platform.* name carrying a kind or unit option +// (those are platform-owned — a reserved name may carry Price ONLY, to +// override the customer passthrough); +// - a duplicate metric name (a second declaration would silently disagree +// on kind/price). func (c *Client) Declare(moduleID string, decl Decl) { ValidateMetricName(decl.Name) - if !decl.Kind.IsValid() { - panic(fmt.Sprintf("mirrorstack/meter: Meter(%q) has invalid kind %q (use ms.Counter or ms.Gauge)", decl.Name, decl.Kind)) + if decl.kindDup { + panic(fmt.Sprintf("mirrorstack/meter: Meter(%q) was given both ms.Counter and ms.Gauge (a metric has exactly one kind)", decl.Name)) + } + if IsReserved(decl.Name) { + // Reserved infra.*/platform.* metrics are platform-measured: the + // platform owns kind/unit/measurement. A module may declare one with + // Price ONLY (a Plane-2 customer-passthrough override). Any kind or + // unit option on a reserved name is a programmer error. + if decl.KindSet { + panic(fmt.Sprintf("mirrorstack/meter: Meter(%q) is a reserved platform metric — it may carry ms.Price only; kind is platform-owned (drop ms.Counter/ms.Gauge)", decl.Name)) + } + if decl.UnitSet { + panic(fmt.Sprintf("mirrorstack/meter: Meter(%q) is a reserved platform metric — it may carry ms.Price only; unit is platform-owned (drop ms.Unit)", decl.Name)) + } + // A reserved declaration's ONLY purpose is to override the customer + // passthrough price. With no Price it is a no-op that would otherwise + // pollute the manifest with a meaningless empty entry — reject it. + if !decl.PriceSet { + panic(fmt.Sprintf("mirrorstack/meter: Meter(%q) is a reserved platform metric — pass ms.Price to override the customer passthrough, or remove this declaration", decl.Name)) + } + } else { + // Custom metric: a kind is mandatory so the platform knows SUM vs + // MAX/integral, and a call site can never mislabel it. + if !decl.KindSet { + panic(fmt.Sprintf("mirrorstack/meter: Meter(%q) needs a kind — pass ms.Counter or ms.Gauge", decl.Name)) + } + if !decl.Kind.IsValid() { + panic(fmt.Sprintf("mirrorstack/meter: Meter(%q) has invalid kind %q (use ms.Counter or ms.Gauge)", decl.Name, decl.Kind)) + } } c.mu.Lock() defer c.mu.Unlock() @@ -205,6 +335,13 @@ func (c *Client) Record(ctx context.Context, name string, value float64) error { decl, declared := c.metrics[name] moduleID := c.moduleID c.mu.RUnlock() + if IsReserved(name) { + // A reserved infra.*/platform.* metric is platform-measured. A module + // may DECLARE it (Price-only, to override the customer passthrough) but + // may never self-report its value — the platform meters it at its own + // chokepoint, and an SDK-reported quantity for it is never billable. + return fmt.Errorf("mirrorstack/meter: metric %q is platform-measured and cannot be self-reported via ms.Record (it may be declared with ms.Price only)", name) + } if !declared { return fmt.Errorf("mirrorstack/meter: metric %q was never declared (call ms.Meter(%q, ...) in setup before ms.Record)", name, name) } diff --git a/meter/meter_test.go b/meter/meter_test.go index f0b93e0..0d1ef86 100644 --- a/meter/meter_test.go +++ b/meter/meter_test.go @@ -132,8 +132,8 @@ func TestMeter_DeclaresKindAndPriceIntoManifest(t *testing.T) { if d.Name != "orders.placed" { t.Errorf("name = %q, want orders.placed", d.Name) } - if d.Kind != Counter { - t.Errorf("kind = %q, want counter", d.Kind) + if !d.KindSet || d.Kind != counterKind { + t.Errorf("kind = %q (set=%v), want counter (set=true)", d.Kind, d.KindSet) } if d.Unit != "order" { t.Errorf("unit = %q, want order", d.Unit) @@ -145,43 +145,164 @@ func TestMeter_DeclaresKindAndPriceIntoManifest(t *testing.T) { // A gauge declared with no price: PriceSet must stay false so the manifest // distinguishes a declared 0 from "no price". g := DeclFromOptions("myapp.objects.bytes", Gauge, Unit("byte")) - if g.Kind != Gauge { - t.Errorf("kind = %q, want gauge", g.Kind) + if !g.KindSet || g.Kind != gaugeKind { + t.Errorf("kind = %q (set=%v), want gauge (set=true)", g.Kind, g.KindSet) } if g.PriceSet { t.Errorf("PriceSet = true for an undeclared price, want false") } } +// TestMeter_KindIsAnOption asserts the kind is passed as an OPTION (Counter / +// Gauge) and lands on the Decl + the manifest registration. This is the core +// of PR #1b: kind moved from a positional argument to a functional option. +func TestMeter_KindIsAnOption(t *testing.T) { + t.Parallel() + c := newTestClient(t, &fakeLambda{}) + // Order-independent: kind option can sit anywhere in the variadic list. + c.Declare("media", DeclFromOptions("orders.placed", Unit("order"), Counter, Price(50_000))) + c.mu.RLock() + got := c.metrics["orders.placed"] + c.mu.RUnlock() + if !got.KindSet || got.Kind != counterKind { + t.Errorf("kind = %q (set=%v), want counter (set=true)", got.Kind, got.KindSet) + } +} + +// TestMeter_RejectsMissingKind asserts a CUSTOM metric declared without a kind +// option panics — the platform must know SUM vs MAX/integral up front. +func TestMeter_RejectsMissingKind(t *testing.T) { + t.Parallel() + c := newTestClient(t, &fakeLambda{}) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on a custom metric declared without a kind") + } + }() + c.Declare("media", DeclFromOptions("orders.placed", Unit("order"), Price(50_000))) +} + +// TestMeter_RejectsConflictingKinds asserts passing both Counter and Gauge +// panics — a metric has exactly one kind. +func TestMeter_RejectsConflictingKinds(t *testing.T) { + t.Parallel() + c := newTestClient(t, &fakeLambda{}) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic when both Counter and Gauge are passed") + } + }() + c.Declare("media", DeclFromOptions("orders.placed", Counter, Gauge)) +} + +// TestMeter_ReservedPriceOnlyAccepted asserts a reserved infra.*/platform.* +// metric declared with PRICE ONLY is accepted (a customer-passthrough override) +// and lands in the registry with NO kind/unit (platform-owned), price set. +func TestMeter_ReservedPriceOnlyAccepted(t *testing.T) { + t.Parallel() + c := newTestClient(t, &fakeLambda{}) + c.Declare("media", DeclFromOptions("infra.compute.ms", Price(0))) + c.mu.RLock() + got, ok := c.metrics["infra.compute.ms"] + c.mu.RUnlock() + if !ok { + t.Fatal("reserved price-override metric should be declared") + } + if got.KindSet || got.Kind != "" { + t.Errorf("reserved override should carry no kind, got %q (set=%v)", got.Kind, got.KindSet) + } + if got.UnitSet { + t.Error("reserved override should carry no unit") + } + if !got.PriceSet || got.Price != 0 { + t.Errorf("price = %d (set=%v), want 0 (set=true)", got.Price, got.PriceSet) + } +} + +// TestMeter_ReservedWithKindOrUnitPanics asserts a reserved metric carrying a +// kind or a unit option panics — those are platform-owned; a reserved name may +// carry Price only. +func TestMeter_ReservedWithKindOrUnitPanics(t *testing.T) { + t.Parallel() + cases := map[string][]MetricOption{ + "kind": {Counter, Price(0)}, + "unit": {Unit("ms"), Price(0)}, + } + for label, opts := range cases { + t.Run(label, func(t *testing.T) { + c := newTestClient(t, &fakeLambda{}) + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic on reserved metric with a %s option", label) + } + }() + c.Declare("media", DeclFromOptions("infra.compute.ms", opts...)) + }) + } +} + +// TestMeter_ReservedWithoutPricePanics asserts a reserved metric declared with +// NO options at all is rejected: its only legitimate purpose is to override the +// customer passthrough via ms.Price, so a price-less reserved declaration is a +// meaningless no-op that would otherwise pollute the manifest. +func TestMeter_ReservedWithoutPricePanics(t *testing.T) { + t.Parallel() + c := newTestClient(t, &fakeLambda{}) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on a reserved metric declared with no ms.Price") + } + }() + c.Declare("media", DeclFromOptions("infra.compute.ms")) +} + +// TestRecord_RejectsReservedName asserts a module can DECLARE a reserved +// price-override but can never self-report its value — ms.Record returns an +// error and never reaches the transport (the platform meters infra itself). +func TestRecord_RejectsReservedName(t *testing.T) { + t.Parallel() + fake := &fakeLambda{} + c := newTestClient(t, fake) + c.Declare("media", DeclFromOptions("infra.compute.ms", Price(0))) + + err := c.Record(context.Background(), "infra.compute.ms", 1) + if err == nil || !strings.Contains(err.Error(), "platform-measured") { + t.Errorf("expected a platform-measured rejection, got %v", err) + } + if fake.invoked != 0 { + t.Errorf("reserved metric must not reach the transport; invoked=%d", fake.invoked) + } +} + // TestRecord_NoKindOnWire asserts the §4 invariant: kind lives in the manifest, // NOT on the wire. The serialized Event must carry no "kind" key (for either a // counter or a gauge), the envelope version must stay 1, and the value is // carried verbatim (V==1). func TestRecord_NoKindOnWire(t *testing.T) { t.Parallel() - for _, k := range []Kind{Counter, Gauge} { + for _, kindOpt := range []MetricOption{Counter, Gauge} { fake := &fakeLambda{} c := newTestClient(t, fake) - c.Declare("store", DeclFromOptions("myapp.items", k)) + c.Declare("store", DeclFromOptions("myapp.items", kindOpt)) if err := c.Record(context.Background(), "myapp.items", 1); err != nil { - t.Fatalf("Record(%s): %v", k, err) + t.Fatalf("Record: %v", err) } var raw map[string]json.RawMessage if err := json.Unmarshal(fake.lastIn.Payload, &raw); err != nil { t.Fatalf("decode payload: %v", err) } if _, ok := raw["kind"]; ok { - t.Errorf("kind=%s: wire envelope must not carry a kind field, got keys %v", k, keys(raw)) + t.Errorf("wire envelope must not carry a kind field, got keys %v", keys(raw)) } var got Event if err := json.Unmarshal(fake.lastIn.Payload, &got); err != nil { t.Fatalf("decode event: %v", err) } if got.V != 1 { - t.Errorf("kind=%s: envelope version = %d, want 1", k, got.V) + t.Errorf("envelope version = %d, want 1", got.V) } if got.Value != 1 { - t.Errorf("kind=%s: value = %g, want 1", k, got.Value) + t.Errorf("value = %g, want 1", got.Value) } } } @@ -289,10 +410,11 @@ func TestRecord_RejectsNegativeAndNonFinite(t *testing.T) { } } -// TestMeter_RejectsReservedPrefix asserts ms.Meter rejects the platform-owned -// infra.*/platform.* namespaces (§3a build rule 3) so a module cannot declare a -// platform-billable metric. Declaration is the enforcement point. -func TestMeter_RejectsReservedPrefix(t *testing.T) { +// TestMeter_ReservedKindOnAnyPrefixPanics asserts the platform-owned +// infra.*/platform.* namespaces (§3a build rule 3) reject a KIND option across +// every reserved prefix — a module may price-override a reserved metric but can +// never declare its kind (that is platform-owned). +func TestMeter_ReservedKindOnAnyPrefixPanics(t *testing.T) { t.Parallel() bad := []string{"infra.compute.ms", "infra.egress.bytes", "platform.storage.bytes", "platform.tokens"} for _, name := range bad { @@ -300,7 +422,7 @@ func TestMeter_RejectsReservedPrefix(t *testing.T) { c := newTestClient(t, &fakeLambda{}) defer func() { if r := recover(); r == nil { - t.Errorf("expected panic on reserved-prefix metric %q", name) + t.Errorf("expected panic on a kind option for reserved metric %q", name) } }() c.Declare("media", DeclFromOptions(name, Counter)) @@ -332,7 +454,10 @@ func TestMeter_RejectsInvalidKind(t *testing.T) { t.Error("expected panic on invalid kind") } }() - c.Declare("media", DeclFromOptions("x.y", Kind("histogram"))) + // A Decl carrying an out-of-range kind (reachable only by constructing the + // Decl directly — the public Counter/Gauge options can't produce it) must + // still be rejected by Declare's IsValid guard. + c.Declare("media", Decl{Name: "x.y", Kind: Kind("histogram"), KindSet: true}) } // TestMeter_RejectsDuplicateName asserts a metric declared twice panics — a diff --git a/mirrorstack.go b/mirrorstack.go index ae10c1b..d854096 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -138,19 +138,22 @@ func Cache(ctx context.Context) (cache.Cacher, func(), error) { return core.Cach // Storage returns a scoped storage client on the default module. func Storage(ctx context.Context) (storage.Storer, error) { return core.Storage(ctx) } -// Kind is a usage-metric's billing semantic: Counter (additive; SUM) or Gauge -// (absolute level; MAX / time-weighted integral, never summed). -type Kind = meter.Kind +// MetricOption configures a metric at declaration. The KIND is itself an option +// (Counter / Gauge), alongside Unit and Price. +type MetricOption = meter.MetricOption -// Counter and Gauge are the two metric kinds passed to Meter. +// Counter and Gauge are the metric-kind options passed to Meter. Counter is +// additive (the platform SUMs it); Gauge is an absolute level (MAX or a +// time-weighted integral, never summed). +// +// They are CONSTANTS, not vars, so a third-party module cannot reassign +// ms.Counter / ms.Gauge (the SDK is a trust boundary). The call site stays +// parens-free: ms.Meter(name, ms.Counter, ...). const ( Counter = meter.Counter Gauge = meter.Gauge ) -// MetricOption configures a metric at declaration (Unit, Price). -type MetricOption = meter.MetricOption - // Unit sets a metric's display unit (e.g. "order", "byte"). Optional. func Unit(u string) MetricOption { return meter.Unit(u) } @@ -161,21 +164,25 @@ func Price(microDollars int64) MetricOption { return meter.Price(microDollars) } // Meter DECLARES a usage metric on the default module. Call it ONCE per metric // in startup code (exactly like ms.Emits / ms.RegisterPermission) — it registers -// the metric as a SIDE EFFECT and returns NOTHING. The declaration (name + kind -// + unit + price) is recorded in the manifest so the platform's metric catalog -// is authoritative before any event arrives. +// the metric as a SIDE EFFECT and returns NOTHING. The declaration (kind + unit +// + price) is recorded in the manifest so the platform's metric catalog is +// authoritative before any event arrives. // -// kind is ms.Counter (additive; platform SUMs) or ms.Gauge (absolute level; -// platform takes MAX or a time-weighted integral). Emit at runtime BY NAME with -// ms.Record(ctx, name, value) — mirroring ms.Emits/ms.Emit; the platform reads -// the declared kind from the catalog, so a call site can never mislabel a metric. +// The kind is an OPTION: pass ms.Counter (additive; platform SUMs) or ms.Gauge +// (absolute level; platform takes MAX or a time-weighted integral). Emit at +// runtime BY NAME with ms.Record(ctx, name, value) — mirroring ms.Emits/ms.Emit; +// the platform reads the declared kind from the catalog, so a call site can +// never mislabel a metric. // -// Panics on a duplicate metric name, an invalid name, an unknown kind, or a -// reserved infra.*/platform.* prefix. +// A custom metric MUST pass exactly one kind option. A reserved +// infra.*/platform.* metric is platform-measured: it may carry ms.Price ONLY +// (to override the customer passthrough) — passing a kind or unit on it panics, +// as does a duplicate name, an invalid name, or conflicting kinds. // // ms.Meter("orders.placed", ms.Counter, ms.Unit("order"), ms.Price(50_000)) -func Meter(name string, kind Kind, opts ...MetricOption) { - core.Meter(name, kind, opts...) +// ms.Meter("infra.compute.ms", ms.Price(0)) // absorb platform compute +func Meter(name string, opts ...MetricOption) { + core.Meter(name, opts...) } // Record emits a usage event for the metric DECLARED (via ms.Meter) under name