Skip to content

feat: meter transport → dispatch-HTTP (POST /apps/{appID}/usage)#124

Merged
I-am-nothing merged 1 commit into
mainfrom
feat/meter-transport-dispatch-http
Jun 18, 2026
Merged

feat: meter transport → dispatch-HTTP (POST /apps/{appID}/usage)#124
I-am-nothing merged 1 commit into
mainfrom
feat/meter-transport-dispatch-http

Conversation

@I-am-nothing

@I-am-nothing I-am-nothing commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Scope (Milestone D, wave-2 PR #2 — SDK transport)

Swaps the usage-meter SDK transport from MS_METER_LAMBDA_ARN lambda.Invoke (a Lambda that never existed) to a dispatch-HTTP POST /apps/{appID}/usage, modeled exactly on ms.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.Record POSTs the Event v1 envelope (no kind on the wire) to {MS_DISPATCH_URL | dev fallback}/apps/{appID}/usage.
  • Removes MS_METER_LAMBDA_ARN, lambda.Invoke, NewFromARN/NewDev, arnPattern, lambdaInvoker, and the aws-sdk-go-v2/service/lambda dependency. meter.New() builds the always-present HTTP client and fail-fast validates MS_DISPATCH_URL when set.
  • appID comes from the request context auth identity; empty appID returns an error (no panic, no HTTP call), mirroring ms.Emit.
  • eventId minted once per Record, reused across any transport retry so billing-engine ON CONFLICT(event_id) dedupes.
  • Public API unchanged: ms.Meter declaration + by-name ms.Record; reserved-name guards intact.
  • Tests rewritten onto an httptest dispatch stub (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.
  • VERSION 0.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-Record synchronous 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).

  • Buffer + fallback. meter.WithBuffer(ctx) installs a ctx-scoped buffer. ms.Record appends 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.
  • Concurrent flush. (*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.
  • Mode-aware lifecycle wiring (core).
    • Lambda: the handler wrapper flushes synchronously before the handler returns. The execution environment freezes after return, so a detached send would be killed mid-flight; NewLambdaHandler builds the LambdaResponse only after ServeHTTP returns, so the sync flush is guaranteed to complete in-invocation.
    • Long-running HTTP server: flush in a background goroutine after the response (detached ctx so a client disconnect can't cancel billing sends); the process is long-lived, so the response is never delayed.
    • Task worker (ECS): each handler is wrapped to buffer + flush synchronously after the handler returns, so events are delivered before the SQS message is acknowledged / the worker drains on SIGTERM.
    • A nil/unconfigured meter client makes every wrapper a pass-through no-op (never panics).
  • Public API still identical (ms.Meter + ms.Record(ctx,name,value)), envelope unchanged, reserved/undeclared/finite/non-negative guards unchanged, eventId still minted once per Record. VERSION stays 0.2.4 (same PR).
  • New tests (meter + core): buffered Record does not POST until Flush; Flush sends all N events and is concurrent; no-buffer Record sends 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

Review follow-ups (non-blocking — Go reviewer: approve, no blocking items)

  • meter/meter.go — bare return err (no fmt.Errorf wrap) on http.NewRequestWithContext and httpClient.Do. Intentional precedent-match to emit.go and call.go where net/http errors are self-describing. Noted inconsistency with the wrapped marshal error in the same function.
  • meter/meter.gob, _ := io.ReadAll(...) discards the read error. Exact mirror of emit.go; non-2xx path already, so a read failure yields an empty diagnostic string. Kept by precedent parity.
  • meter/meter.goappID interpolated into the URL path via fmt.Sprintf with no url.PathEscape. Same pattern as emit.go and call.go resolveCallURL; AppID values are UUID-format (no path-unsafe chars). Track as a follow-up if AppID format ever relaxes (use url.JoinPath).
  • meter/Client holds a per-instance *http.Client while emit.go/call.go share a package-level callHTTP var. Per-instance is deliberate here for test isolation via newDispatchStub; flagged so a future "align with emit" refactor doesn't undo the test benefit.

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 I-am-nothing merged commit d257f22 into main Jun 18, 2026
1 check passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant