feat: meter transport → dispatch-HTTP (POST /apps/{appID}/usage)#124
Merged
Conversation
Replace the usage-meter transport with a dispatch-HTTP POST, modeled
exactly on ms.Emit. ms.Record now POSTs the Event envelope to the
platform dispatch usage ingress at {MS_DISPATCH_URL | dev fallback}/
apps/{appID}/usage in both dev and prod; dispatch re-derives the
authoritative app/module/owner/recorded_at and forwards to
billing-engine, so the SDK Hint fields stay untrusted.
- Remove MS_METER_LAMBDA_ARN, lambda.Invoke, NewFromARN/NewDev,
arnPattern, lambdaInvoker, and the aws-sdk-go-v2/service/lambda
dependency. A single meter.New() builds the always-present HTTP
client and fail-fast validates MS_DISPATCH_URL when set.
- appID comes from the request context's auth identity; an empty
appID returns an error (no panic, no HTTP call), mirroring ms.Emit.
- EventID is still minted once per Record and reused across any
transport retry so ON CONFLICT(event_id) dedupes.
- Public API unchanged: ms.Meter declaration + ms.Record by-name,
envelope v1 with no kind on the wire, reserved-name guards intact.
- Tests rewritten onto an httptest dispatch stub (posts to
/apps/{appID}/usage, retry reuses eventId, empty-app errors without
HTTP, dev fallback URL, non-2xx surfaces body); ARN/Lambda tests
removed.
- VERSION 0.2.3 -> 0.2.4 + CHANGELOG entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
I-am-nothing
added a commit
that referenced
this pull request
Jun 18, 2026
Move the usage-meter transport from an AWS Lambda invoke to a dispatch-HTTP
POST, exactly mirroring ms.Emit/ms.Call: ms.Record sends the usage Event
envelope to {MS_DISPATCH_URL | dev fallback}/apps/{appID}/usage in both dev
and prod. Drops MS_METER_LAMBDA_ARN, the ARN constructors, and the aws-sdk
lambda dependency; meter.New() fail-fast validates MS_DISPATCH_URL. The public
metering API, the v1 envelope (no kind on the wire), the reserved-namespace
guards, and the once-per-Record EventID are unchanged.
Add request-scoped, mode-aware batched flush so a per-Record synchronous POST
no longer sits in the module's request hot path:
- meter: a ctx-scoped buffer (WithBuffer). Record appends to the buffer when
one is present and returns; with no buffer (dev/cron/outside a request) it
falls back to today's inline synchronous send. Client.Flush(ctx) drains the
buffer and POSTs the events CONCURRENTLY (bounded), one event per POST (the
dispatch ingress contract is unchanged). Send errors are logged and swallowed
(non-fatal contract unchanged); nil client / empty buffer are no-ops.
- core: wire the request lifecycle. The HTTP handler wrapper installs a buffer
per request and flushes mode-aware -- SYNCHRONOUSLY before returning on
Lambda (the exec env freezes after return, so a detached send is unsafe), in
a BACKGROUND goroutine after the response on the long-running HTTP server.
The task worker wraps each handler to buffer + flush synchronously after the
handler returns (before the SQS message is acknowledged). A nil meter client
makes every wrapper a pass-through no-op.
VERSION stays 0.2.4 (same PR #124).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scope (Milestone D, wave-2 PR #2 — SDK transport)
Swaps the usage-meter SDK transport from
MS_METER_LAMBDA_ARNlambda.Invoke(a Lambda that never existed) to a dispatch-HTTPPOST /apps/{appID}/usage, modeled exactly onms.Emit. Same path in dev and prod; dispatch re-derives the authoritative app/module/owner/recorded_at and forwards to billing-engine, so the SDK Hint fields stay untrusted.ms.RecordPOSTs the Event v1 envelope (nokindon the wire) to{MS_DISPATCH_URL | dev fallback}/apps/{appID}/usage.MS_METER_LAMBDA_ARN,lambda.Invoke,NewFromARN/NewDev,arnPattern,lambdaInvoker, and theaws-sdk-go-v2/service/lambdadependency.meter.New()builds the always-present HTTP client and fail-fast validatesMS_DISPATCH_URLwhen set.appIDcomes from the request context auth identity; emptyappIDreturns an error (no panic, no HTTP call), mirroringms.Emit.eventIdminted once perRecord, reused across any transport retry so billing-engineON CONFLICT(event_id)dedupes.ms.Meterdeclaration + by-namems.Record; reserved-name guards intact.newDispatchStub): POST to/apps/{appID}/usage, retry reuses eventId, empty-app errors without HTTP, dev fallback URL, non-2xx surfaces body. ARN/Lambda tests removed.VERSION0.2.3 → 0.2.4 + CHANGELOG.Design ref:
docs-temp/milestone-d-meter/design.md§4 (Axis 2 — async ingestion path), §5 (app-module-sdk PR #2).Request-scoped, mode-aware batched flush
Adds a request-scoped buffer so a per-
Recordsynchronous POST no longer sits in the module's request hot path (each inline POST added a round-trip, up to the 15s timeout, to the handler).meter.WithBuffer(ctx)installs a ctx-scoped buffer.ms.Recordappends to the buffer when present and returns immediately; with no buffer (dev / cron / any call outside a wrapped request) it falls back to today's inline synchronous send — current behavior preserved exactly.(*Client).Flush(ctx)drains the buffer and sends every buffered event concurrently (bounded by a small semaphore), one event per POST — the dispatch ingress contract is unchanged (still one event per POST), so "batch" = concurrent sends, not a combined payload. Send failures are logged and swallowed (non-fatal contract unchanged); a nil client / absent / empty buffer is a no-op.NewLambdaHandlerbuilds theLambdaResponseonly afterServeHTTPreturns, so the sync flush is guaranteed to complete in-invocation.ms.Meter+ms.Record(ctx,name,value)), envelope unchanged, reserved/undeclared/finite/non-negative guards unchanged,eventIdstill minted once perRecord.VERSIONstays 0.2.4 (same PR).Recorddoes not POST untilFlush;Flushsends all N events and is concurrent; no-bufferRecordsends immediately (fallback); flush errors swallowed; double-flush is a no-op (buffer drained); Lambda-mode wrapper flushes before returning; background flush lands eventually; nil-client HTTP + task wrappers are no-ops.Build / tests
go build ./... && go vet ./... && go test ./...clean (incl.examples/template);go test -race ./meter/ ./internal/core/clean.DEFERRED
POST /apps/{appID}/usageingress that terminates this wire, plusinternal/shared/billingRecordUsage/GetUsageSummary/SetModuleVisibility/SetMetricDefinitionsactions, manifest metric sync, and infra price-override sync +Ensuredelinquency enforcement → tracked for PR Events & scheduling: ms.OnEvent(), ms.Emit(), ms.Cron() #9/Database package: ms.DB(ctx) #10. Dev E2E provable once SDK chore: update license to FSL-1.1-ALv2, rewrite README, add CONTRIBUTING #1+docs: add SDK package structure to README #2 and the api ingress meet at the wire.RecordUsageleg authenticates with a dedicated meter secret (X-MS-Meter-Secret/ envMETER_SECRET, billing-engine docs: update README to reflect implemented SDK core #16), distinct fromX-MS-Internal-Secret. The SDK→dispatch leg here uses no secret (loopback-trust in dev, #146 sender identity in prod); the meter secret lives on the dispatch→billing hop, set in the dev stack env.Review follow-ups (non-blocking — Go reviewer: approve, no blocking items)
meter/meter.go— barereturn err(nofmt.Errorfwrap) onhttp.NewRequestWithContextandhttpClient.Do. Intentional precedent-match toemit.goandcall.gowhere net/http errors are self-describing. Noted inconsistency with the wrapped marshal error in the same function.meter/meter.go—b, _ := io.ReadAll(...)discards the read error. Exact mirror ofemit.go; non-2xx path already, so a read failure yields an empty diagnostic string. Kept by precedent parity.meter/meter.go—appIDinterpolated into the URL path viafmt.Sprintfwith nourl.PathEscape. Same pattern asemit.goandcall.goresolveCallURL; AppID values are UUID-format (no path-unsafe chars). Track as a follow-up if AppID format ever relaxes (useurl.JoinPath).meter/Clientholds a per-instance*http.Clientwhileemit.go/call.goshare a package-levelcallHTTPvar. Per-instance is deliberate here for test isolation vianewDispatchStub; flagged so a future "align with emit" refactor doesn't undo the test benefit.