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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.1
0.2.2
8 changes: 6 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 40 additions & 5 deletions examples/template/meter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions internal/core/meter_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
4 changes: 3 additions & 1 deletion internal/core/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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") },
Expand Down
63 changes: 48 additions & 15 deletions internal/core/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Loading
Loading