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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

## [v0.2.4] - 2026-06-17

The usage-meter transport moves from an AWS Lambda invoke to a dispatch-HTTP POST, exactly mirroring `ms.Emit`. The public metering API (`ms.Meter` declaration, `ms.Record` emit-by-name, the v1 envelope with no kind on the wire, the reserved-namespace guards) is unchanged — only how a recorded event reaches the platform changes.

### Changed
- **Meter transport is now dispatch-HTTP, not Lambda.** `ms.Record` POSTs the usage `Event` envelope to the platform dispatch usage ingress at `{MS_DISPATCH_URL | dev fallback}/apps/{appID}/usage` (the same transport idiom as `ms.Emit` / `ms.Call`), in **both dev and prod** — there is no separate dev sink. Dispatch re-derives the authoritative app/module/owner/recorded_at and forwards to billing-engine; the SDK's `Hint` fields stay untrusted. The app id comes from the request context's auth identity (`auth.Get`), and an empty app id returns an error (no panic, no HTTP call), mirroring `ms.Emit`. The transport HTTP client is built once at `meter.New()` and is never nil. The non-fatal contract is unchanged: a transport failure is returned (then logged by the caller), never propagated to fail the handler. The `EventID` is still minted once per `Record` call and reused across any transport retry, so the platform's `ON CONFLICT(event_id)` dedupe holds.

### Removed
- **`MS_METER_LAMBDA_ARN` and the AWS Lambda meter transport.** The `meter` package no longer invokes a Lambda: the `MS_METER_LAMBDA_ARN` environment variable is gone, along with `meter.NewFromARN` / `meter.NewDev` (replaced by a single `meter.New()`), the ARN-format validation, and the `github.com/aws/aws-sdk-go-v2/service/lambda` dependency. Usage transport is configured via **`MS_DISPATCH_URL`** (the same base `ms.Emit` / `ms.Call` use); `meter.New()` fail-fast validates it as a parseable http(s) base when set, so a typo surfaces at startup rather than as silently lost usage. An unset `MS_DISPATCH_URL` falls back to the local dispatch for dev.

## [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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.3
0.2.4
1 change: 0 additions & 1 deletion examples/template/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25 // indirect
Expand Down
2 changes: 0 additions & 2 deletions examples/template/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3x
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0 h1:e4NAllPs/ygQ7W4dTlAuP5N7QpCT+rTij3S8UOv2DD4=
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0/go.mod h1:6HBXRyFFqOw+ALkJ6YGHfrr20/YXYv6X9pcZErXRvCA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 h1:foqo/ocQ7WqKwy3FojGtZQJo0FR4vto9qnz9VaumbCo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25
github.com/go-chi/chi/v5 v5.2.5
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3x
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0 h1:e4NAllPs/ygQ7W4dTlAuP5N7QpCT+rTij3S8UOv2DD4=
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0/go.mod h1:6HBXRyFFqOw+ALkJ6YGHfrr20/YXYv6X9pcZErXRvCA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 h1:foqo/ocQ7WqKwy3FojGtZQJo0FR4vto9qnz9VaumbCo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
Expand Down
27 changes: 24 additions & 3 deletions internal/core/meter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,30 @@ package core
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/mirrorstack-ai/app-module-sdk/auth"
"github.com/mirrorstack-ai/app-module-sdk/meter"
"github.com/mirrorstack-ai/app-module-sdk/system"
)

// usageStub starts a dispatch usage-ingress stand-in (202), points
// MS_DISPATCH_URL at it, and returns an app-scoped context. Record POSTs the
// usage Event here (the transport is dispatch-HTTP in dev + prod, mirroring
// ms.Emit), so a successful Record needs both an app-scoped ctx and a reachable
// dispatch.
func usageStub(t *testing.T) context.Context {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
}))
t.Cleanup(srv.Close)
t.Setenv("MS_DISPATCH_URL", srv.URL)
return auth.Set(context.Background(), auth.Identity{AppID: "a-456"})
}

// 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
Expand Down Expand Up @@ -117,10 +135,11 @@ func TestMeter_ReservedPriceOverrideInManifest(t *testing.T) {
// 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) {
ctx := usageStub(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 {
if err := m.Record(ctx, "infra.compute.ms", 1); err == nil {
t.Error("expected an error recording a reserved platform-measured metric")
}
}
Expand All @@ -136,21 +155,23 @@ func TestMeter_TopLevelPanicsBeforeInit(t *testing.T) {
// for a DECLARED metric succeeds (dev client logs, returns nil), mirroring
// ms.Emit's emit-by-name shape.
func TestRecord_ResolvesDeclaredByName(t *testing.T) {
ctx := usageStub(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 {
if err := m.Record(ctx, "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) {
ctx := usageStub(t)
m, _ := New(Config{ID: "media"})
m.Meter("orders.placed", meter.Counter)

if err := m.Record(context.Background(), "never.declared", 1); err == nil {
if err := m.Record(ctx, "never.declared", 1); err == nil {
t.Error("expected an error recording an undeclared metric name")
}
}
23 changes: 10 additions & 13 deletions internal/core/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ type Module struct {
// once" contract as internalAuth/proxyGuard — so every Platform()
// registration reuses one closure instead of re-reading env + re-allocating
// the closure (and its secret snapshot) on each call.
platformAuth func(http.Handler) http.Handler
platformAuth func(http.Handler) http.Handler
poolCache *db.PoolCache // production: per-app DB pools
devDBOnce sync.Once // dev mode: lazy DB init
devDB *db.DB
Expand All @@ -125,7 +125,7 @@ type Module struct {
taskHandlers map[string]taskEntry // registered task handlers (startup-only writes)
sqsClient *msqs.Client // nil in dev mode (MS_TASK_QUEUE_URL unset)
signingKey []byte // HMAC key for TaskMessage signing (MS_TASK_SIGNING_KEY)
meterClient *meter.Client // prod (MS_METER_LAMBDA_ARN set) or dev-mode stderr
meterClient *meter.Client // dispatch-HTTP usage transport (dev + prod); never nil

// devMode is true under `mirrorstack dev` (HTTP serving + dev DB), false in
// Lambda/task-worker. It gates the dev-only per-app schema machinery:
Expand Down Expand Up @@ -266,18 +266,15 @@ func New(cfg Config) (*Module, error) {
m.sqsClient = sqsClient
}

// Meter client: production mode when MS_METER_LAMBDA_ARN is set (ARN is
// validated at construction so typos fail fast), dev-mode stderr sink
// otherwise. Never nil.
if meterARN := os.Getenv("MS_METER_LAMBDA_ARN"); meterARN != "" {
meterClient, err := meter.NewFromARN(context.Background(), meterARN)
if err != nil {
return nil, fmt.Errorf("mirrorstack: init meter client: %w", err)
}
m.meterClient = meterClient
} else {
m.meterClient = meter.NewDev(m.logger)
// Meter client: dispatch-HTTP transport in both dev and prod (Record POSTs
// each usage Event to the dispatch usage ingress, like ms.Emit). The HTTP
// client is always built (never nil); New fail-fast validates MS_DISPATCH_URL
// when set so a typo surfaces at startup rather than as silently lost usage.
meterClient, err := meter.New()
if err != nil {
return nil, fmt.Errorf("mirrorstack: init meter client: %w", err)
}
m.meterClient = meterClient

// A Config-provided description flows to the registry so it reaches the
// manifest like Name/Tags. Skip when empty to avoid a blank override.
Expand Down
Loading
Loading