Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.2
0.2.3
5 changes: 3 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
18 changes: 14 additions & 4 deletions examples/template/meter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 ...
Expand Down
42 changes: 40 additions & 2 deletions internal/core/meter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
44 changes: 24 additions & 20 deletions internal/core/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
Loading
Loading