diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c619f..5ab12c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [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. + +### Changed (BREAKING) +- **`ms.Meter` is a DECLARATION with no return value.** `ms.Meter(name string, kind ms.Kind, opts ...ms.MetricOption)` declares a metric once in startup code (exactly like `ms.Emits` / `ms.RegisterPermission`) — it registers the metric as a side effect and returns **nothing** (no `*ms.Metric` handle). `ms.Kind` is `ms.Counter` (additive; the platform SUMs) or `ms.Gauge` (absolute level; the platform takes MAX or a time-weighted integral, never a SUM). Options: `ms.Unit(string)` and `ms.Price(microDollars int64)` — the per-unit **customer** price (charged as quantity × price with NO blanket markup); both optional. The old runtime accessor `ms.Meter(ctx) Meter`, its `Record`/`Gauge` methods, **and the `*ms.Metric` handle type are removed**. +- **Emit by name with `ms.Record(ctx, name, value) error`.** One package-level function, mirroring `ms.Emit`: it resolves the metric declared under `name` and hands it to the transport. The platform reads the declared kind from its manifest-fed catalog to decide SUM vs MAX/integral, so the call site never repeats the kind. Returns an error (does **not** panic) when `name` was never declared via `ms.Meter` (declaration-first, fail fast) or when the value is negative, NaN, or infinite; the non-fatal contract is unchanged (transport failures are logged, not propagated). The `EventID` is minted once per `Record` and reused across any transport retry. +- **`kind` does not travel on the wire.** The metric kind lives in the manifest/catalog, so the meter `Event` envelope carries no `kind` field and `envelopeVersion` stays **1**. + +### Added +- **Manifest `metrics[]`** — each declared metric (`{name, kind, unit, price}`) appears in the module manifest so the platform populates its `metric_definitions` catalog at install/publish. +- **Reserved-namespace + duplicate guards.** `ms.Meter` panics on a duplicate metric name (two declarations would silently disagree on kind/price) or on a reserved `infra.*` / `platform.*` prefix (those are platform-measured infra metrics a module may not self-declare). + ## [v0.2.1] - 2026-06-13 A module can now read its own **trusted app id** on every guarded surface — including Public routes, which previously had no identity at all. diff --git a/VERSION b/VERSION index 0c62199..ee1372d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/docs/api-reference.md b/docs/api-reference.md index 7ce7889..d7086c5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -117,10 +117,14 @@ 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(ctx).Record(metric, value)` | Emit a billing event via async Lambda invoke. | +| `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. | ```go -ms.Meter(r.Context()).Record("transcode.minutes", 12) +// setup +ms.Meter("transcode.minutes", ms.Counter, ms.Unit("minute"), ms.Price(50_000)) +// handler +ms.Record(r.Context(), "transcode.minutes", 12) ``` ## Agent surface (MCP) diff --git a/examples/template/meter.go b/examples/template/meter.go index cc95ef7..043fa25 100644 --- a/examples/template/meter.go +++ b/examples/template/meter.go @@ -3,8 +3,21 @@ package main // CLI flag: --use-meter // Remove this file if the module doesn't emit billable usage events. // -// Call Record sparingly — once per meaningful action, not per row processed. -// Billing errors are logged but must not fail the calling handler. +// DECLARE each metric ONCE, up front, with its kind + unit + price; then emit +// at runtime BY NAME with a single ms.Record(ctx, name, value) — exactly +// mirroring ms.Emits (declare) / ms.Emit (emit by name). Declaration registers +// the metric into the manifest, so the platform's catalog knows how to +// aggregate and price it before any event arrives; there is no handle to keep. +// +// Use ms.Counter for additive counts (orders placed, minutes transcoded) and +// ms.Gauge for an absolute current level you re-report on a heartbeat (your own +// 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). import ( "log" @@ -19,11 +32,33 @@ func init() { } func registerMeter() { + // Counter: an additive business metric priced at $0.05/order (50_000 + // micro-dollars). The platform SUMs counters over the billing period. + ms.Meter("orders.placed", ms.Counter, ms.Unit("order"), ms.Price(50_000)) + + // Gauge: an absolute current level of a MODULE-OWNED metric (here, total + // bytes in the module's own external store). ms.Record reports the CURRENT + // absolute level (not a delta); the platform never sums a gauge — it takes + // the MAX or a time-weighted integral over the billing period, so the price + // is charged per aggregated byte-hour / peak (the platform's rollup choice), + // NOT once per reported sample. Re-report on a heartbeat (design §7). The + // price here is illustrative — a real byte gauge with a per-byte price can + // produce large invoices, so pick the cadence + price deliberately. + ms.Meter("myapp.objects.bytes", ms.Gauge, ms.Unit("byte"), ms.Price(1)) + ms.Platform(func(r chi.Router) { - r.Post("/transcode", func(w http.ResponseWriter, r *http.Request) { - // ... do the transcode work ... + r.Post("/orders", func(w http.ResponseWriter, r *http.Request) { + // ... place the order ... + + // Emit BY NAME — the metric was declared above. The platform reads + // the declared kind from its catalog, so the call site never repeats it. + if err := ms.Record(r.Context(), "orders.placed", 1); err != nil { + log.Printf("meter: %v", err) // don't fail the handler + } - if err := ms.Meter(r.Context()).Record("transcode.minutes", 12); err != nil { + // Report the current absolute level of the module's own store. + currentBytes := 4096.0 + if err := ms.Record(r.Context(), "myapp.objects.bytes", currentBytes); err != nil { log.Printf("meter: %v", err) // don't fail the handler } w.WriteHeader(http.StatusOK) diff --git a/internal/core/meter_test.go b/internal/core/meter_test.go new file mode 100644 index 0000000..c3fec77 --- /dev/null +++ b/internal/core/meter_test.go @@ -0,0 +1,118 @@ +package core + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mirrorstack-ai/app-module-sdk/meter" + "github.com/mirrorstack-ai/app-module-sdk/system" +) + +// TestMeter_DeclaresKindAndPriceIntoManifest asserts that ms.Meter records the +// declared name + kind + unit + price into the manifest's metrics[] (the path +// the platform reads to populate its metric_definitions catalog). Mirrors the +// Emits/Permissions manifest tests. +func TestMeter_DeclaresKindAndPriceIntoManifest(t *testing.T) { + m := newTestModuleWithSecret(t, "media") + + m.Meter("orders.placed", meter.Counter, meter.Unit("order"), meter.Price(50_000)) + m.Meter("myapp.objects.bytes", meter.Gauge, meter.Unit("byte")) // no price + + 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) != 2 { + t.Fatalf("metrics = %d, want 2: %+v", len(got.Metrics), got.Metrics) + } + for _, d := range got.Metrics { + switch d.Name { + case "orders.placed": + if d.Kind != "counter" || d.Unit != "order" { + t.Errorf("orders.placed = %+v, want kind=counter unit=order", d) + } + if d.Price == nil || *d.Price != 50_000 { + t.Errorf("orders.placed price = %v, want 50000", d.Price) + } + case "myapp.objects.bytes": + if d.Kind != "gauge" || d.Unit != "byte" { + t.Errorf("myapp.objects.bytes = %+v, want kind=gauge unit=byte", d) + } + if d.Price != nil { + t.Errorf("myapp.objects.bytes price = %v, want nil (no price declared)", d.Price) + } + default: + t.Errorf("unexpected metric %q", d.Name) + } + } +} + +// TestMeter_PriceZeroIsDistinctFromUnpriced asserts a declared price of 0 is +// carried (PriceSet), distinct from omitting Price entirely. +func TestMeter_PriceZeroIsDistinctFromUnpriced(t *testing.T) { + m := newTestModuleWithSecret(t, "media") + m.Meter("free.metric", meter.Counter, meter.Price(0)) + + 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", len(got.Metrics)) + } + if got.Metrics[0].Price == nil || *got.Metrics[0].Price != 0 { + t.Errorf("price = %v, want explicit 0", got.Metrics[0].Price) + } +} + +func TestMeter_PanicsOnDuplicateName(t *testing.T) { + m, _ := New(Config{ID: "media"}) + m.Meter("orders.placed", meter.Counter) + + assertPanics(t, "expected panic on duplicate Meter name", func() { + m.Meter("orders.placed", meter.Gauge) + }) +} + +func TestMeter_PanicsOnReservedPrefix(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() { + m.Meter(name, meter.Counter) + }) + } +} + +func TestMeter_TopLevelPanicsBeforeInit(t *testing.T) { + resetDefault(t) + assertPanics(t, "expected panic for top-level Meter before Init", func() { + Meter("orders.placed", meter.Counter) + }) +} + +// TestRecord_ResolvesDeclaredByName asserts the by-name runtime emit: ms.Record +// for a DECLARED metric succeeds (dev client logs, returns nil), mirroring +// ms.Emit's emit-by-name shape. +func TestRecord_ResolvesDeclaredByName(t *testing.T) { + m, _ := New(Config{ID: "media"}) + m.Meter("orders.placed", meter.Counter, meter.Unit("order")) + + if err := m.Record(context.Background(), "orders.placed", 1); err != nil { + t.Fatalf("Record of a declared metric: %v", err) + } +} + +// TestRecord_RejectsUndeclaredName asserts declaration-first: ms.Record for a +// name never declared via ms.Meter returns an error (no silent emit). +func TestRecord_RejectsUndeclaredName(t *testing.T) { + m, _ := New(Config{ID: "media"}) + m.Meter("orders.placed", meter.Counter) + + if err := m.Record(context.Background(), "never.declared", 1); err == nil { + t.Error("expected an error recording an undeclared metric name") + } +} diff --git a/internal/core/module_test.go b/internal/core/module_test.go index 15ae2d1..bb4b3b4 100644 --- a/internal/core/module_test.go +++ b/internal/core/module_test.go @@ -14,6 +14,7 @@ import ( "github.com/mirrorstack-ai/app-module-sdk/db" "github.com/mirrorstack-ai/app-module-sdk/internal/migration" "github.com/mirrorstack-ai/app-module-sdk/internal/registry" + "github.com/mirrorstack-ai/app-module-sdk/meter" p "github.com/mirrorstack-ai/app-module-sdk/roles" "github.com/mirrorstack-ai/app-module-sdk/system" ) @@ -959,7 +960,8 @@ func TestScopesPanic_BeforeInit(t *testing.T) { "Cron": func() { Cron("cleanup", "0 3 * * *", func(w http.ResponseWriter, r *http.Request) {}) }, "OnTask": func() { OnTask("work", func(ctx context.Context, p json.RawMessage) error { return nil }) }, "RunTask": func() { _, _ = RunTask(context.Background(), "work", nil) }, - "Meter": func() { _ = Meter(context.Background()).Record("m", 1) }, + "Meter": func() { Meter("m", meter.Counter) }, + "Record": func() { _ = Record(context.Background(), "m", 1) }, "ModuleDB": func() { _, _, _ = ModuleDB(context.Background()) }, "ModuleTx": func() { _ = ModuleTx(context.Background(), func(q db.Querier) error { return nil }) }, "DependsOn": func() { DependsOn("other") }, diff --git a/internal/core/resources.go b/internal/core/resources.go index 4760c7e..0127886 100644 --- a/internal/core/resources.go +++ b/internal/core/resources.go @@ -5,6 +5,7 @@ import ( "github.com/mirrorstack-ai/app-module-sdk/auth" "github.com/mirrorstack-ai/app-module-sdk/cache" + "github.com/mirrorstack-ai/app-module-sdk/internal/registry" "github.com/mirrorstack-ai/app-module-sdk/meter" "github.com/mirrorstack-ai/app-module-sdk/storage" ) @@ -73,20 +74,47 @@ func (m *Module) resolveStorage(ctx context.Context) (*storage.Client, error) { return m.devStorage, m.devStorageErr } -// Meter returns a scoped meter for recording usage events (billing metrics). -// Unlike DB/Cache/Storage, Meter returns the interface directly — there is -// no release closure (nothing to release) and no construction error -// (init errors happen eagerly in New()). +// 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. // -// In production (MS_METER_LAMBDA_ARN set), Record dispatches to the platform -// meter Lambda via async invoke (~5-15ms per call). In dev mode, Record -// logs to stderr. +// 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). // -// if err := ms.Meter(r.Context()).Record("transcode.minutes", 12); err != nil { -// log.Printf("meter: %v", err) // don't fail the handler -// } -func (m *Module) Meter(ctx context.Context) meter.Meter { - return m.meterClient.Scope(ctx, m.config.ID) +// 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). +// +// 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). + m.meterClient.Declare(m.config.ID, d) + decl := registry.MetricDecl{Name: d.Name, Kind: string(d.Kind), Unit: d.Unit} + if d.PriceSet { + p := d.Price + decl.Price = &p + } + m.registry.AddMetric(decl) +} + +// Record emits a usage event for the metric declared (via Meter) under name — +// BY NAME, mirroring Emit. Resolves the declared metric from the meter client's +// registry; returns an error (never panics) if name was never declared, or if +// value is negative/non-finite. A billing failure must never fail the handler, +// so the error should be logged, not propagated. +func (m *Module) Record(ctx context.Context, name string, value float64) error { + return m.meterClient.Record(ctx, name, value) } // Package-level convenience wrappers — dispatch to defaultModule. @@ -101,8 +129,13 @@ func Storage(ctx context.Context) (storage.Storer, error) { return mustDefault("Storage").Storage(ctx) } -// Meter returns a scoped meter for recording usage events on the default module. +// Meter declares a usage metric on the default module (side effect, no return). // Panics before Init. -func Meter(ctx context.Context) meter.Meter { - return mustDefault("Meter").Meter(ctx) +func Meter(name string, kind meter.Kind, opts ...meter.MetricOption) { + mustDefault("Meter").Meter(name, kind, opts...) +} + +// Record emits a usage event by name on the default module. Panics before Init. +func Record(ctx context.Context, name string, value float64) error { + return mustDefault("Record").Record(ctx, name, value) } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 847be41..c4736dc 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -121,6 +121,23 @@ type OutboundContribution struct { Payload json.RawMessage `json:"payload"` // typed payload, validated by the host's slot at registration } +// MetricDecl is a declared usage metric (ms.Meter). Exposed in the manifest so +// the platform populates its metric_definitions catalog (kind, unit, customer +// price) at install/publish — BEFORE any usage event arrives, so the catalog +// is authoritative for how a metric aggregates and is priced. +// +// Kind is "counter" (additive; SUM) or "gauge" (absolute level; MAX/integral). +// 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"` + Unit string `json:"unit,omitempty"` + Price *int64 `json:"price,omitempty"` +} + // MCPToolHandler is the type-erased handler signature used after generic // MCPTool registration at the SDK level has wrapped the typed handler. type MCPToolHandler func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) @@ -161,6 +178,7 @@ type Registry struct { schedules []Schedule tasks []Task permissions []Permission + metrics []MetricDecl description string dependencies []Dependency outboundContributions []OutboundContribution @@ -521,6 +539,49 @@ func (r *Registry) Permissions() []Permission { return out } +// AddMetric records a declared usage metric. Returns true if added, false if a +// metric with the same name already exists. The caller (ms.Meter via meter. +// Declare) owns name + kind + reserved-prefix validation; this only dedups by +// name. Unlike the first-wins declarations (AddEmit/AddPermission), a duplicate +// metric name is treated as a programmer error by the caller (ms.Meter panics +// on a false return) — two declarations of the same metric with different +// kind/price would silently disagree, so it must fail loudly. +func (r *Registry) AddMetric(d MetricDecl) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, existing := range r.metrics { + if existing.Name == d.Name { + return false + } + } + if d.Price != nil { + p := *d.Price + d.Price = &p + } + r.metrics = append(r.metrics, d) + return true +} + +// Metrics returns a non-nil deep copy of all declared metrics in registration +// order. The Price pointer on each entry is cloned so caller mutations cannot +// leak back into the registry. +func (r *Registry) Metrics() []MetricDecl { + r.mu.RLock() + defer r.mu.RUnlock() + if r.metrics == nil { + return []MetricDecl{} + } + out := make([]MetricDecl, len(r.metrics)) + for i, d := range r.metrics { + out[i] = d + if d.Price != nil { + p := *d.Price + out[i].Price = &p + } + } + return out +} + // AddMCPTool registers an MCP tool. First-wins: if a tool with the same name // already exists, the call is a no-op and returns false. Panics on an invalid // name (see ValidateName). diff --git a/meter/event.go b/meter/event.go index 891380a..c1078ff 100644 --- a/meter/event.go +++ b/meter/event.go @@ -3,19 +3,35 @@ package meter import "time" // envelopeVersion is the current wire format version. Bump on breaking changes. +// +// Still v1: the metric KIND lives in the module manifest / platform catalog, +// not on the wire, so adding declaration-first metering required no envelope +// change. (If a wire-incompatible change ever ships post-launch, bump here.) const envelopeVersion = 1 -// Event is the JSON wire format sent to the platform meter Lambda. +// Event is the JSON wire format sent to the platform meter ingress. +// +// There is deliberately NO kind field: a metric's kind (counter/gauge) is +// declared once via ms.Meter and travels in the manifest, so the platform's +// catalog is the single authoritative source — a call site can never mislabel +// a metric's semantic on the wire. // // Fields ending in Hint are SDK-asserted values the platform uses for // debugging and logging but MUST NOT trust for billing attribution. The -// authoritative values come from the AWS invoker identity (context.FunctionArn) -// cross-checked against a platform-owned Lambda→app mapping. +// authoritative values are re-derived platform-side from the authenticated +// invoker (Axis 2 of the Milestone D design). // // ModuleIDHint is required (no omitempty) even though it is labeled a hint — // the "Hint" suffix means "SDK-asserted, not authoritative," not "optional." // AppIDHint is omitempty because system-triggered events may have no app // context (e.g., cron job running without an AppID in ctx). +// +// EventID is the at-least-once retry dedup key: the platform ingest is +// idempotent on it (ON CONFLICT(event_id) DO NOTHING), so the SAME logical +// Record call must reuse the SAME EventID across any transport retry. It is +// minted ONCE per Record call (before the event is handed to the transport) +// and the built Event is reused across any transport retry within that call, +// so a retried delivery is deduped rather than double-counted. type Event struct { V int `json:"v"` EventID string `json:"eventId"` diff --git a/meter/meter.go b/meter/meter.go index 7054e34..49cc037 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -1,10 +1,20 @@ -// Package meter emits usage events for billing to a platform-owned AWS -// Lambda function via async invoke. See the Meter interface for the API. +// Package meter is the module usage-metering surface. A module DECLARES each +// metric once, up front, with its kind + unit + price; runtime code then emits +// BY NAME with a single Record call — exactly mirroring ms.Emits (declare) / +// ms.Emit (emit by name). Declaration registers the metric into the module +// MANIFEST (via the registry, in core) AND into the module's meter registry, so +// the platform can populate its metric catalog BEFORE any event arrives — and a +// call site can never mislabel a metric's kind, because kind is read from the +// catalog, not the wire. There is NO stored handle: Record resolves the metric +// by name and fails fast if it was never declared (declaration-first). // -// Security model: IAM invoke permission scoped to the exact meter ARN is -// the sole access control. Wire-format fields suffixed with Hint (AppIDHint, -// ModuleIDHint, RecordedAtHint) are NOT trusted — the platform meter Lambda -// re-derives authoritative values from the invoker's AWS identity. +// Security model: the SDK runs inside the module's own (untrusted) process. +// Wire-format fields suffixed with Hint (AppIDHint, ModuleIDHint, +// RecordedAtHint) are NOT trusted — the platform re-derives authoritative +// values from the authenticated invoker. Reported VALUES affect only the +// developer's own customer billing. The reserved infra.*/platform.* namespace +// is rejected at declaration (and at platform ingress) so a module cannot +// declare or self-report a platform-billable infra metric. package meter import ( @@ -12,7 +22,10 @@ import ( "encoding/json" "fmt" "log" + "math" "regexp" + "strings" + "sync" "time" "github.com/aws/aws-sdk-go-v2/config" @@ -21,7 +34,6 @@ import ( "github.com/mirrorstack-ai/app-module-sdk/auth" "github.com/mirrorstack-ai/app-module-sdk/internal/ids" - "github.com/mirrorstack-ai/app-module-sdk/internal/registry" ) // arnPattern matches a valid Lambda function ARN. Validated at Client @@ -29,111 +41,269 @@ import ( // rather than at first Record call (silent revenue loss otherwise). var arnPattern = regexp.MustCompile(`^arn:aws:lambda:[a-z0-9-]+:[0-9]+:function:[a-zA-Z0-9_-]+$`) -// Meter records usage events for billing. -type Meter interface { - // Record emits a usage event via async Lambda invoke (production) or - // stderr log (dev mode). Synchronous: blocks for the duration of the - // Lambda control-plane round-trip (~5-15ms). - // - // Call sparingly — once per meaningful action, not per row processed. - // Errors should be logged, not propagated — billing failures should - // never fail the handler. - // - // Metric names must not contain path separators (/, \), whitespace, - // dot-segments (..), or null bytes. Panics on invalid name. - Record(metric string, value float64) error -} +// reservedPrefixes are the platform-owned metric namespaces. A module may not +// declare or self-report a metric under these — they belong to platform-side +// infra metering (model tokens, storage bytes, egress, compute), which is +// measured at the platform chokepoint and never trusts an SDK value. ms.Meter +// panics on a reserved name, and the platform ingress rejects it too. +var reservedPrefixes = []string{"infra.", "platform."} -// lambdaInvoker is the subset of lambda.Client used by Client. Makes the -// Lambda invoke path mockable in unit tests. -type lambdaInvoker interface { - Invoke(ctx context.Context, params *lambda.InvokeInput, optFns ...func(*lambda.Options)) (*lambda.InvokeOutput, error) +// Kind is the billing semantic of a declared metric. It is fixed at +// 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. +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" +) + +// IsValid reports whether k is one of the two known kinds. +func (k Kind) IsValid() bool { return k == Counter || k == Gauge } + +// 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) + +type metricOptions struct { + unit string + price int64 + priceSet bool } -// Client is the module-level meter client. Created eagerly at Module.New() -// when MS_METER_LAMBDA_ARN is set. Nil in dev mode. -type Client struct { - lambdaClient lambdaInvoker - functionARN string - logger *log.Logger // dev-mode stderr sink when lambdaClient is nil +// 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 } } -// NewFromARN creates a production meter client for the given Lambda function -// ARN. Validates ARN format against arnPattern. Uses the module's default -// AWS IAM role (same pattern as internal/sqs/client.go). -func NewFromARN(ctx context.Context, arn string) (*Client, error) { - if !arnPattern.MatchString(arn) { - return nil, fmt.Errorf("mirrorstack/meter: invalid ARN format %q (expected arn:aws:lambda:::function:)", arn) +// Price sets the metric's per-unit CUSTOMER price in micro-dollars (1e-6 USD). +// This is the developer's Plane-2 pricing for THEIR customer; the platform +// charges quantity × this price with NO blanket markup (the flat 1.2× applies +// 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) { + o.price = microDollars + o.priceSet = true } - cfg, err := config.LoadDefaultConfig(ctx) - if err != nil { - return nil, fmt.Errorf("mirrorstack/meter: load aws config: %w", err) +} + +// 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 { + o := &metricOptions{} + for _, opt := range opts { + if opt != nil { + opt(o) + } } - return &Client{ - lambdaClient: lambda.NewFromConfig(cfg), - functionARN: arn, - }, nil + return Decl{Name: name, Kind: kind, Unit: o.unit, Price: o.price, PriceSet: o.priceSet} } -// NewDev creates a dev-mode meter client that logs Record calls to the given -// logger (typically Module.logger writing to stderr). Returns a non-nil -// Client; Meter.Record is a no-op beyond the log line. -func NewDev(logger *log.Logger) *Client { - return &Client{logger: logger} +// 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. +type Decl struct { + Name string + Kind Kind + Unit string + Price int64 + PriceSet bool } -// Scope returns a Meter bound to the current request context. The AppID is -// read from auth.Get(ctx) as a hint only — the platform does not trust it. -func (c *Client) Scope(ctx context.Context, moduleID string) Meter { - appID := "" - if a := auth.Get(ctx); a != nil { - appID = a.AppID +// 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. +func ValidateMetricName(name string) { + if name == "" { + panic("mirrorstack/meter: Meter name cannot be empty") } - return &scopedMeter{ - ctx: ctx, - client: c, - moduleID: moduleID, - appID: appID, + if strings.ContainsAny(name, "/\\ \t\n\r\x00") { + panic("mirrorstack/meter: Meter(" + name + ") contains a path separator, whitespace, or null byte") + } + 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)") + } } } -type scopedMeter struct { - ctx context.Context - client *Client - moduleID string - appID string +// Declare validates a metric declaration and registers it into the client's +// metric registry under decl.Name, so a later Record(ctx, name, value) +// resolves it BY NAME (mirroring how ms.Emits records an emit name that +// ms.Emit later resolves). NO handle is returned — emission is by name. +// +// The caller (core) is responsible for registering decl into the MANIFEST +// (registry.AddMetric); Declare owns the name/kind/reserved-prefix validation +// 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). +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)) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.moduleID == "" { + c.moduleID = moduleID + } + if c.metrics == nil { + c.metrics = make(map[string]Decl) + } + if _, dup := c.metrics[decl.Name]; dup { + panic("mirrorstack/meter: Meter(" + decl.Name + ") declared twice") + } + c.metrics[decl.Name] = decl } -func (s *scopedMeter) Record(metric string, value float64) error { - registry.ValidateName("Record", metric) +// Record emits a usage event for the metric declared under name with the given +// value. It mirrors ms.Emit: resolve the declared name, build the envelope, +// hand it to the transport. The platform reads the declared kind from its +// manifest-fed catalog to decide SUM vs MAX/integral, so a call site can never +// mislabel a metric. +// +// Declaration-first: if name was never declared via ms.Meter, Record returns an +// error (fail fast in dev) — it never silently emits an unknown metric. +// +// Emitted via async Lambda invoke (production) or stderr log (dev mode). +// Call sparingly — once per meaningful action, not per row processed. Errors +// are returned, NOT panicked, and should be logged, not propagated: a billing +// failure must never fail the handler. +// +// Returns an error (does NOT panic) if value is negative, NaN, or infinite — +// a single bad value can't crash the handler. The metric name was already +// validated at declaration. +// +// The EventID is minted ONCE per Record call and reused across any transport +// retry within the call, so the platform's ON CONFLICT(event_id) dedupe holds +// for a retried delivery (the retry loop lands with the transport rewrite). +func (c *Client) Record(ctx context.Context, name string, value float64) error { + c.mu.RLock() + decl, declared := c.metrics[name] + moduleID := c.moduleID + c.mu.RUnlock() + if !declared { + return fmt.Errorf("mirrorstack/meter: metric %q was never declared (call ms.Meter(%q, ...) in setup before ms.Record)", name, name) + } + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 { + return fmt.Errorf("mirrorstack/meter: metric %q: value must be finite and non-negative, got %g", decl.Name, value) + } + + appID := "" + if a := auth.Get(ctx); a != nil { + appID = a.AppID + } - // Dev mode: log to stderr and return. The appID may be empty if the - // context has no auth identity (e.g., internal route, test harness). - if s.client.lambdaClient == nil { - s.client.logger.Printf("meter: appID=%q moduleID=%q metric=%q value=%g", s.appID, s.moduleID, metric, value) + // Dev mode: log to stderr and return. appID may be empty if the context + // has no auth identity (internal route, test harness). A zero Client{} (or + // one built via NewFromARN before the logger is wired) may have a nil + // logger, so guard it — a missing dev sink must not panic the handler. + if c.lambdaClient == nil { + if c.logger != nil { + c.logger.Printf("meter: appID=%q moduleID=%q metric=%q value=%g", appID, moduleID, decl.Name, value) + } return nil } + // Mint the EventID ONCE here so a retried delivery reuses it. NO kind on + // the wire — the manifest/catalog is authoritative. event := Event{ V: envelopeVersion, EventID: ids.NewUUID(), - AppIDHint: s.appID, - ModuleIDHint: s.moduleID, - Metric: metric, + AppIDHint: appID, + ModuleIDHint: moduleID, + Metric: decl.Name, Value: value, RecordedAtHint: time.Now().UTC(), } + return c.dispatch(ctx, event) +} + +// dispatch delivers an already-built Event to the transport. The caller mints +// the EventID once (in Record), so retrying dispatch with the same Event reuses +// the same EventID and the platform deduplicates rather than double-counts. +func (c *Client) dispatch(ctx context.Context, event Event) error { body, err := json.Marshal(event) if err != nil { return fmt.Errorf("mirrorstack/meter: marshal event: %w", err) } - _, err = s.client.lambdaClient.Invoke(s.ctx, &lambda.InvokeInput{ - FunctionName: &s.client.functionARN, + _, err = c.lambdaClient.Invoke(ctx, &lambda.InvokeInput{ + FunctionName: &c.functionARN, InvocationType: types.InvocationTypeEvent, // async fire-and-forget Payload: body, }) if err != nil { - return fmt.Errorf("mirrorstack/meter: invoke %s: %w", s.client.functionARN, err) + return fmt.Errorf("mirrorstack/meter: invoke %s: %w", c.functionARN, err) } return nil } + +// lambdaInvoker is the subset of lambda.Client used by Client. Makes the +// Lambda invoke path mockable in unit tests. +type lambdaInvoker interface { + Invoke(ctx context.Context, params *lambda.InvokeInput, optFns ...func(*lambda.Options)) (*lambda.InvokeOutput, error) +} + +// Client is the module-level meter transport AND metric registry. Created +// eagerly at ms.Init: a production client (MS_METER_LAMBDA_ARN set) +// async-invokes the platform meter Lambda; a dev client logs to stderr. +// Declared metrics live in its registry, keyed by name, so Record resolves a +// metric by name (mirroring ms.Emits/ms.Emit). +// +// The dispatch-HTTP transport rewrite is a follow-up (PR #2); this PR keeps +// the existing MS_METER_LAMBDA_ARN / lambda.Invoke transport. +type Client struct { + lambdaClient lambdaInvoker + functionARN string + logger *log.Logger // dev-mode stderr sink when lambdaClient is nil + + mu sync.RWMutex + moduleID string // emitting module's Config.ID, set at first Declare + metrics map[string]Decl // declared metrics, keyed by name (Record resolves here) +} + +// NewFromARN creates a production meter client for the given Lambda function +// ARN. Validates ARN format against arnPattern. Uses the module's default +// AWS IAM role (same pattern as internal/sqs/client.go). +func NewFromARN(ctx context.Context, arn string) (*Client, error) { + if !arnPattern.MatchString(arn) { + return nil, fmt.Errorf("mirrorstack/meter: invalid ARN format %q (expected arn:aws:lambda:::function:)", arn) + } + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("mirrorstack/meter: load aws config: %w", err) + } + return &Client{ + lambdaClient: lambda.NewFromConfig(cfg), + functionARN: arn, + }, nil +} + +// NewDev creates a dev-mode meter client that logs Record calls to the given +// logger (typically Module.logger writing to stderr). +func NewDev(logger *log.Logger) *Client { + return &Client{logger: logger} +} diff --git a/meter/meter_test.go b/meter/meter_test.go index 99e3fed..f0b93e0 100644 --- a/meter/meter_test.go +++ b/meter/meter_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "log" + "math" "strings" "testing" @@ -38,8 +39,18 @@ func newTestClient(t *testing.T, fake *fakeLambda) *Client { } } +// declareCounter is a test helper: declares a counter metric on c bound to +// module "media", so Record(ctx, name, ...) resolves it by name. +func declareCounter(t *testing.T, c *Client, name string) { + t.Helper() + c.Declare("media", DeclFromOptions(name, Counter)) +} + func TestNewFromARN_ValidARN(t *testing.T) { - t.Parallel() + // config.LoadDefaultConfig probes IMDS for region/credentials; disable it so + // the test is hermetic and skips the ~100ms IMDS round-trip in CI/sandboxes + // without AWS credentials. (Not t.Parallel: t.Setenv forbids parallel.) + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") _, err := NewFromARN(context.Background(), "arn:aws:lambda:us-east-1:123456789012:function:meter") if err != nil { t.Fatalf("NewFromARN with valid ARN: %v", err) @@ -71,18 +82,15 @@ func TestRecord_ProdInvokesLambda(t *testing.T) { t.Parallel() fake := &fakeLambda{} c := newTestClient(t, fake) + declareCounter(t, c, "transcode.minutes") ctx := auth.Set(context.Background(), auth.Identity{AppID: "app_abc", AppRole: "admin"}) - m := c.Scope(ctx, "media") - if err := m.Record("transcode.minutes", 12); err != nil { + if err := c.Record(ctx, "transcode.minutes", 12); err != nil { t.Fatalf("Record: %v", err) } if fake.invoked != 1 { t.Errorf("Lambda invoked %d times, want 1", fake.invoked) } - if *fake.lastIn.FunctionName != "arn:aws:lambda:us-east-1:123456789012:function:meter-test" { - t.Errorf("wrong function name") - } if fake.lastIn.InvocationType != types.InvocationTypeEvent { t.Errorf("invocation type = %v, want Event (async)", fake.lastIn.InvocationType) } @@ -114,25 +122,115 @@ func TestRecord_ProdInvokesLambda(t *testing.T) { } } +// TestMeter_DeclaresKindAndPriceIntoManifest asserts the declaration carries the +// kind / unit / price the platform populates metric_definitions from (the core +// ms.Meter facade hands this Decl to both registry.AddMetric and the meter +// registry). Declaration must not drop any of kind/unit/price. +func TestMeter_DeclaresKindAndPriceIntoManifest(t *testing.T) { + t.Parallel() + d := DeclFromOptions("orders.placed", Counter, Unit("order"), Price(50_000)) + 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.Unit != "order" { + t.Errorf("unit = %q, want order", d.Unit) + } + if !d.PriceSet || d.Price != 50_000 { + t.Errorf("price = %d (set=%v), want 50000 (set=true)", d.Price, d.PriceSet) + } + + // 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.PriceSet { + t.Errorf("PriceSet = true for an undeclared price, want false") + } +} + +// 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} { + fake := &fakeLambda{} + c := newTestClient(t, fake) + c.Declare("store", DeclFromOptions("myapp.items", k)) + if err := c.Record(context.Background(), "myapp.items", 1); err != nil { + t.Fatalf("Record(%s): %v", k, 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)) + } + 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) + } + if got.Value != 1 { + t.Errorf("kind=%s: value = %g, want 1", k, got.Value) + } + } +} + +func keys(m map[string]json.RawMessage) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + func TestRecord_PropagatesLambdaError(t *testing.T) { t.Parallel() fake := &fakeLambda{err: errors.New("throttled")} c := newTestClient(t, fake) + declareCounter(t, c, "transcode.minutes") - m := c.Scope(context.Background(), "media") - err := m.Record("transcode.minutes", 1) + err := c.Record(context.Background(), "transcode.minutes", 1) if err == nil || !strings.Contains(err.Error(), "throttled") { t.Errorf("expected wrapped throttled error, got %v", err) } } +// TestRecord_RejectsUndeclaredName asserts the declaration-first contract: a +// Record for a name never declared via ms.Meter returns an error and never +// reaches the transport. +func TestRecord_RejectsUndeclaredName(t *testing.T) { + t.Parallel() + fake := &fakeLambda{} + c := newTestClient(t, fake) + declareCounter(t, c, "transcode.minutes") + + err := c.Record(context.Background(), "never.declared", 1) + if err == nil || !strings.Contains(err.Error(), "never declared") { + t.Errorf("expected an undeclared-name error, got %v", err) + } + if fake.invoked != 0 { + t.Errorf("undeclared metric must not reach the transport; invoked=%d", fake.invoked) + } +} + func TestRecord_DevModeLogsToStderr(t *testing.T) { var buf bytes.Buffer c := NewDev(log.New(&buf, "", 0)) + c.Declare("media", DeclFromOptions("transcode.minutes", Counter)) ctx := auth.Set(context.Background(), auth.Identity{AppID: "dev_app"}) - m := c.Scope(ctx, "media") - if err := m.Record("transcode.minutes", 12); err != nil { + if err := c.Record(ctx, "transcode.minutes", 12); err != nil { t.Fatalf("Record: %v", err) } @@ -143,35 +241,146 @@ func TestRecord_DevModeLogsToStderr(t *testing.T) { !strings.Contains(out, `value=12`) { t.Errorf("unexpected log line: %q", out) } + if strings.Contains(out, "kind=") { + t.Errorf("dev log must not carry a kind (catalog is authoritative): %q", out) + } +} + +func TestRecord_NilLoggerDoesNotPanic(t *testing.T) { + t.Parallel() + // A zero Client{} (legal via the exported type, reachable in tests/mocks) + // has both lambdaClient and logger nil. Record must take the dev-mode path + // (lambdaClient == nil) and return without dereferencing the nil logger. + c := &Client{} + c.Declare("media", DeclFromOptions("transcode.minutes", Counter)) + + ctx := auth.Set(context.Background(), auth.Identity{AppID: "dev_app"}) + if err := c.Record(ctx, "transcode.minutes", 7); err != nil { + t.Fatalf("Record with nil logger should be a no-op, got: %v", err) + } } func TestRecord_DevMode_EmptyAppIDWhenNoAuth(t *testing.T) { var buf bytes.Buffer c := NewDev(log.New(&buf, "", 0)) + c.Declare("media", DeclFromOptions("transcode.minutes", Counter)) - m := c.Scope(context.Background(), "media") - _ = m.Record("transcode.minutes", 1) + _ = c.Record(context.Background(), "transcode.minutes", 1) if !strings.Contains(buf.String(), `appID=""`) { t.Errorf("expected appID=\"\" when context has no auth identity, got: %q", buf.String()) } } -func TestRecord_InvalidMetricNamePanics(t *testing.T) { +func TestRecord_RejectsNegativeAndNonFinite(t *testing.T) { t.Parallel() fake := &fakeLambda{} c := newTestClient(t, fake) - m := c.Scope(context.Background(), "media") + declareCounter(t, c, "transcode.minutes") + + bad := []float64{-1, math.NaN(), math.Inf(1), math.Inf(-1)} + for _, v := range bad { + if err := c.Record(context.Background(), "transcode.minutes", v); err == nil { + t.Errorf("Record(%g) should return an error (finite, non-negative)", v) + } + } + if fake.invoked != 0 { + t.Errorf("invalid values must not reach the transport; invoked=%d", fake.invoked) + } +} + +// 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) { + t.Parallel() + bad := []string{"infra.compute.ms", "infra.egress.bytes", "platform.storage.bytes", "platform.tokens"} + for _, name := range bad { + t.Run(name, func(t *testing.T) { + c := newTestClient(t, &fakeLambda{}) + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic on reserved-prefix metric %q", name) + } + }() + c.Declare("media", DeclFromOptions(name, Counter)) + }) + } +} +func TestMeter_RejectsInvalidName(t *testing.T) { + t.Parallel() bad := []string{"", "has/slash", "has space", "has..dots", "null\x00byte"} - for _, metric := range bad { - t.Run(metric, func(t *testing.T) { + for _, name := range bad { + t.Run(name, func(t *testing.T) { + c := newTestClient(t, &fakeLambda{}) defer func() { if r := recover(); r == nil { - t.Errorf("expected panic on invalid metric name %q", metric) + t.Errorf("expected panic on invalid metric name %q", name) } }() - _ = m.Record(metric, 1) + c.Declare("media", DeclFromOptions(name, Counter)) }) } } + +func TestMeter_RejectsInvalidKind(t *testing.T) { + t.Parallel() + c := newTestClient(t, &fakeLambda{}) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on invalid kind") + } + }() + c.Declare("media", DeclFromOptions("x.y", Kind("histogram"))) +} + +// TestMeter_RejectsDuplicateName asserts a metric declared twice panics — a +// second declaration would silently disagree on kind/price. +func TestMeter_RejectsDuplicateName(t *testing.T) { + t.Parallel() + c := newTestClient(t, &fakeLambda{}) + c.Declare("media", DeclFromOptions("orders.placed", Counter)) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic on a duplicate metric declaration") + } + }() + c.Declare("media", DeclFromOptions("orders.placed", Gauge)) +} + +// TestEventID_StableAcrossRetry asserts the §5 invariant: the EventID is minted +// ONCE per Record call and reused across any transport retry, so the platform's +// ON CONFLICT(event_id) dedupe holds and a retried delivery is not +// double-counted. We Record once (mints the EventID), then re-dispatch the same +// built event (simulating a transport retry) and assert the EventID is stable. +func TestEventID_StableAcrossRetry(t *testing.T) { + t.Parallel() + fake := &fakeLambda{} + c := newTestClient(t, fake) + declareCounter(t, c, "transcode.minutes") + + if err := c.Record(context.Background(), "transcode.minutes", 1); err != nil { + t.Fatalf("Record: %v", err) + } + var first Event + if err := json.Unmarshal(fake.lastIn.Payload, &first); err != nil { + t.Fatalf("decode first payload: %v", err) + } + if first.EventID == "" { + t.Fatal("EventID should be set") + } + + // Retry of the SAME logical call must reuse the SAME EventID. dispatch is + // the per-attempt transport leg; Record owns the minted event. + if err := c.dispatch(context.Background(), first); err != nil { + t.Fatalf("dispatch retry: %v", err) + } + var retried Event + if err := json.Unmarshal(fake.lastIn.Payload, &retried); err != nil { + t.Fatalf("decode retry payload: %v", err) + } + if retried.EventID != first.EventID { + t.Errorf("EventID changed across retry: first=%q retry=%q (ON CONFLICT cannot dedupe)", first.EventID, retried.EventID) + } +} diff --git a/mirrorstack.go b/mirrorstack.go index 1a9e646..ae10c1b 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -138,8 +138,62 @@ 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) } -// Meter returns a scoped meter for recording usage events on the default module. -func Meter(ctx context.Context) meter.Meter { return core.Meter(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 + +// Counter and Gauge are the two metric kinds passed to Meter. +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) } + +// Price sets a metric's per-unit CUSTOMER price in micro-dollars (1e-6 USD). +// Optional — omit to meter without charging. The platform charges +// quantity × this price with NO blanket markup for a module's custom metric. +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. +// +// 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. +// +// Panics on a duplicate metric name, an invalid name, an unknown kind, or a +// reserved infra.*/platform.* prefix. +// +// 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...) +} + +// Record emits a usage event for the metric DECLARED (via ms.Meter) under name +// — BY NAME, exactly mirroring ms.Emit. The platform reads the declared kind +// from its catalog to decide how to aggregate, so the call site never repeats +// the kind. +// +// Declaration-first: Record returns an error if name was never declared via +// ms.Meter (fail fast). It also returns an error (never panics) if value is +// negative or non-finite. A billing failure must NEVER fail the handler — log +// the error, don't propagate it. +// +// if err := ms.Record(r.Context(), "orders.placed", 1); err != nil { +// log.Printf("meter: %v", err) // don't fail the handler +// } +func Record(ctx context.Context, name string, value float64) error { + return core.Record(ctx, name, value) +} // --- Inter-module calls --- diff --git a/system/manifest.go b/system/manifest.go index 123f63f..f417509 100644 --- a/system/manifest.go +++ b/system/manifest.go @@ -32,7 +32,12 @@ type ManifestPayload struct { Schedules []registry.Schedule `json:"schedules"` Tasks []registry.Task `json:"tasks"` Permissions []registry.Permission `json:"permissions"` - MCP ManifestMCP `json:"mcp"` + // Metrics lists the usage metrics this module declares (ms.Meter). The + // platform populates its metric_definitions catalog (kind/unit/price) from + // this at install/publish, so the catalog is authoritative before any usage + // event arrives. Omitted when the module declares no metrics. + Metrics []registry.MetricDecl `json:"metrics,omitempty"` + MCP ManifestMCP `json:"mcp"` // UI is the module's declared UI surface (RegisterUI). Nil/absent when // the module ships no UI — callers must nil-check before reading. UI *registry.ModuleUI `json:"ui,omitempty"` @@ -152,6 +157,7 @@ func ManifestHandler(id, slug, name, icon string, tags []string, sqlFS fs.FS, ve Schedules: reg.Schedules(), Tasks: reg.Tasks(), Permissions: reg.Permissions(), + Metrics: reg.Metrics(), MCP: buildManifestMCP(reg), UI: reg.UI(), Provides: contribSlots,