Gateway · Cost events
Per-call cost-event emission — CostEmitter contract, CostMiddleware factory, and four reference adapters (OTel, Postgres, Datadog, Honeycomb).
Every successful gateway call emits one CostEvent to the configured
destination. The cadence is per-call, not batched: accuracy and
reproducibility for compliance attestation outweigh the batching
savings. A destination that wants its own batching implements it
internally — and the four reference adapters that follow do exactly
that.
CostEmitter contract
interface CostEmitter {
emit(event: CostEvent): Promise<void> | void
flush?(): Promise<void>
}CostEmitter widens the existing CostEventSink primitive used by
the Phase A GatewayClient — it adds an optional flush() for
buffered adapters. Every adapter is structurally a CostEventSink
too: code that holds a CostEmitter can pass it anywhere a
CostEventSink is expected.
Implementations MUST:
- Never throw synchronously from
emit()— failures inside an adapter MUST NOT propagate up the gateway call. Buffer + log, or swallow with an internal counter. - Tolerate being called from the host's async context. The middleware
does not wrap the call in
try/catch; adapters own their own failure surface.
Implementations MAY:
- Return a
Promisefromemit()to await a remote write. - Return
voidfromemit()if they buffer internally and flush asynchronously (Postgres pool, Honeycomb batch). - Implement
flush()if they buffer. Callers wireflush()into their shutdown hook to drain pending events before exit.
CostMiddleware factory
The wire-up. Takes a CostEmitter plus a computeCost mapper and
returns a middleware function callable after each route response.
computeCost is generic over the observation shape you pass it. The
output of runtime.routeChatCompletion(input) carries only
{ providerInvoked, modelInvoked, content, usage, costUsd, routingDecisionId } —
it does not carry tenantId, family, callClass, byokActive,
or the full routingDecision. Those come from the request input
plus your own routing context, so merge them onto the observation at
the call site and read them in computeCost:
import { createCostMiddleware, createOtelCostEmitter } from "@pleach/gateway/cost"
import { asTenantId } from "@pleach/gateway"
import type {
RouteChatCompletionInput,
RouteChatCompletionOutput,
} from "@pleach/gateway"
// The merged observation: route output + the fields the host supplies.
type CostObservation = RouteChatCompletionOutput & {
tenantId: string
family: RouteChatCompletionInput["family"]
callClass: "synthesize" | "reasoning" | "converse" | "utility"
byokActive: boolean
}
const middleware = createCostMiddleware<CostObservation>({
emitter: createOtelCostEmitter({ meter }),
computeCost: (route, response) => ({
type: "domain.gateway.cost.recorded",
tenantId: asTenantId(response.tenantId), // non-empty: sourced from input.tenantId
family: response.family,
callClass: response.callClass,
modelInvoked: response.modelInvoked,
costUsd: response.costUsd,
promptTokens: response.usage.promptTokens,
completionTokens: response.usage.completionTokens,
byokActive: response.byokActive,
routingDecision: {
family: response.family,
call_class: response.callClass,
selected_model: response.modelInvoked,
transport: "openrouter",
byok_active: response.byokActive,
upgrade_path_invoked: false,
fallback_chain: [],
raw_provider_cost_usd: response.costUsd,
markup_pct: 0,
},
timestamp: new Date().toISOString(),
}),
})
const output = await runtime.routeChatCompletion(input)
void middleware({
route: "chat.completion",
response: {
...output,
tenantId: input.tenantId,
family: input.family,
callClass: "converse", // host routing context — not on the route output
byokActive: false, // host routing context — not on the route output
},
})routeChatCompletion is the Pack-221 slice-2 stub today: it
validates input and identifies the target provider but returns
costUsd: 0 and a stub content until the real transport lands.
Wire the cost path now so it's ready; the cost numbers go live with
the transport.
The route field is a free-form string identifier — typically
"chat.completion", "embedding", or a tenant-scoped slug. The
middleware does not interpret it; it forwards verbatim to
computeCost.
computeCost MAY return null to skip emission for a given
observation (e.g. cached responses where no provider cost was
incurred). Returning null is structurally identical to a buffered
no-op; do not throw to skip — throw indicates a real error.
onError is optional but recommended. Default behavior when
computeCost throws OR emitter.emit rejects is to swallow —
gateway calls MUST NOT fail because the cost destination is
unavailable. Set onError to forward to your observability layer.
The factory returns middleware & { flush }, so middleware.flush()
on shutdown drains buffered adapters before exit.
Reference adapters
Four adapters cover the common cost destinations. All are structurally
duck-typed against the host's existing client (pg.Pool,
@opentelemetry/api Meter, global fetch) so no vendor SDK is a
hard dependency.
OTel
import { metrics } from "@opentelemetry/api"
import { createOtelCostEmitter } from "@pleach/gateway/cost"
const meter = metrics.getMeter("@pleach/gateway")
const emitter = createOtelCostEmitter({ meter })Emits two counters per CostEvent:
pleach_gateway_cost_usd(Counter, double) — incremented byevent.costUsd.pleach_gateway_tokens_total(Counter, long) — incremented byevent.promptTokens + event.completionTokens, with attributetoken.kind = "prompt" | "completion"recorded once per token-class so the dimension is preserved downstream.
Attributes on every measurement: tenant.id, provider.family,
call.class, model.invoked, byok.active, cost.event.type.
The OTel SDK is an optional peer. The factory accepts a meter
argument duck-typed against @opentelemetry/api's Meter shape; the
adapter never imports the SDK at module-eval time. Override counter
names via costCounterName / tokensCounterName.
Postgres
import { Pool } from "pg"
import { createPostgresCostEmitter } from "@pleach/gateway/cost"
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const emitter = createPostgresCostEmitter({
pool,
table: "pleach_gateway_cost_events",
batchSize: 100,
flushIntervalMs: 5000,
})Buffered. emit() pushes into an in-memory buffer and returns
immediately; flush is either timer-driven (default 5s), threshold-
driven (default 100 events), or manual via flush().
Suggested schema (the byok_key_hash column is nullable; everything
else is NOT NULL):
CREATE TABLE pleach_gateway_cost_events (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
tenant_id TEXT NOT NULL,
family TEXT NOT NULL,
call_class TEXT NOT NULL,
model_invoked TEXT NOT NULL,
cost_usd NUMERIC(18, 8) NOT NULL,
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
byok_active BOOLEAN NOT NULL,
byok_key_hash TEXT,
routing_decision JSONB NOT NULL,
ts TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON pleach_gateway_cost_events (tenant_id, ts DESC);
CREATE INDEX ON pleach_gateway_cost_events (family, call_class);tenant_id is the canonical multi-tenant scoping column. Hosts with
RLS MAY add a policy referencing current_tenant(); the adapter
does NOT set per-connection session variables — that is the
consumer's pool configuration.
The table option is validated against
/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/ (bare
identifier or schema.table form) at construction. Postgres
parameterized identifiers are not supported by the protocol; the
allowlist is the safe floor.
Datadog
import { createDatadogCostEmitter } from "@pleach/gateway/cost"
const emitter = createDatadogCostEmitter({
apiKey: process.env.DD_API_KEY!,
site: "datadoghq.com",
flushIntervalMs: 10_000,
batchSize: 100,
})POSTs to the Datadog /api/v2/series metrics submission endpoint
using global fetch — no SDK dependency. EU customers use
datadoghq.eu; US3 customers use us3.datadoghq.com; etc. The
factory does NOT validate site against an allowlist (Datadog adds
sites over time).
Two series per event:
pleach.gateway.cost.usd(count,event.costUsd)pleach.gateway.tokens.total(count, prompt + completion)
Both series carry the tags tenant_id, family, call_class,
model, byok. The token series additionally records prompt /
completion as two separate points tagged
token_kind:prompt / token_kind:completion.
Honeycomb
import { createHoneycombCostEmitter } from "@pleach/gateway/cost"
const emitter = createHoneycombCostEmitter({
apiKey: process.env.HONEYCOMB_API_KEY!,
dataset: "pleach-gateway",
apiHost: "api.honeycomb.io",
flushIntervalMs: 5_000,
batchSize: 50,
})POSTs to https://<apiHost>/1/batch/<dataset> using global fetch —
no SDK dependency. EU customers may use api.eu1.honeycomb.io.
Each CostEvent becomes one Honeycomb event with flat top-level
fields (tenant_id, family, call_class, model_invoked,
cost_usd, prompt_tokens, completion_tokens, tokens_total,
byok_active, byok_key_hash, event_type) — Honeycomb auto-indexes
them all. The full routingDecision is serialized as
routing_decision_json so Honeycomb users can derive query fields
without changing the adapter.
Production-readiness notes
The three buffered adapters (Postgres, Datadog, Honeycomb) share a small set of production hardening tactics worth understanding before operating them at scale:
-
unref()on interval timers. Each adapter calls.unref()on its periodic-flushsetIntervalhandle. The host process exits cleanly when the cost adapter is the only outstanding handle — important for CLI tools and short-lived workers. Non-Node runtimes (Workers, Deno) don't exposeunref(); the adapter type-guards the call so this is harmless cross-runtime. -
Failed batches re-buffer at the head. When a flush fails (HTTP 5xx, pool exhausted, network reset), the failed batch is
unshift()-ed back to the front of the buffer. Ordering is preserved across the retry — the next flush replays the same batch in the same sequence.onErroris invoked on every failure for observability. -
1000-iter
flush()safety cap. On graceful shutdown,flush()clears the interval timer and drains repeatedly until the buffer is empty. A safety counter caps the drain loop at 1000 iterations so a pathological re-buffer (every batch fails forever) cannot wedge shutdown. Hosts whose drain budget exceeds1000 × batchSizeevents should log and force-exit. -
Emit-side never throws.
emit()isvoidfor the buffered adapters and never propagates a synchronous failure. The middleware'svoid middleware(...)call is fire-and-forget by design; cost emission is structurally decoupled from the gateway's hot path.
Enforcing a per-root-turn cost ceiling
To cap swarm spend, compose RootTurnCostAggregator with
CostCeilingMiddleware from @pleach/gateway/swarm at your gateway
integration seam. The aggregator rolls per-sub-agent cost events up
under the originating rootTurnId; the middleware runs a pre- and
post-call check, fires a once-per-rootTurnId warning at a configured
soft threshold, and throws CostCeilingExceededError on a hard breach.
Per-root-turn aggregation is the right granularity for swarm
deployments: a single user message may fan out to N sub-agents, each
emitting M cost events. The aggregator rolls per-sub-agent events up
under the originating rootTurnId so the ceiling applies to the
user-visible turn cost, not the per-sub-agent slice.
Cited source
packages/gateway/src/cost/CostEmitter.ts— contract.packages/gateway/src/cost/CostMiddleware.ts— middleware factory.packages/gateway/src/cost/adapters/otel.ts— OTel adapter.packages/gateway/src/cost/adapters/postgres.ts— Postgres adapter.packages/gateway/src/cost/adapters/datadog.ts— Datadog adapter.packages/gateway/src/cost/adapters/honeycomb.ts— Honeycomb adapter.packages/gateway/test/costEmitterSurface.smoke.test.mjs— surface regression-lock.
Where to go next
OpenTelemetry integration
Today's OTel surface is the cost-event adapter; full Tracer + span emission is roadmap.
BYOK credential routing
byokActive + byokKeyHash flow through every CostEvent for attribution.
`@pleach/gateway` overview
The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam.
Gateway · Rate limiting
Rate-limit contract and the in-process reference adapter — RateLimiter interface, fixed-window InMemoryLimiter, and the roadmap for distributed adapters.
Gateway · OpenTelemetry integration
OTel surface for `@pleach/gateway` — what's shipped today (cost counters), what's roadmap (tracer + spans).