Metering
Per-tenant cost-attribution + budget enforcement for multi-tenant Bun runtimes. Consumes handlerMetrics from @absolutejs/sync, lifecycle events from @absolutejs/runtime, and periodic observation events; rolls them up per tenant; trips a fail-closed circuit when any per-tenant budget dimension is exceeded.
#Quick Start
The meter is wire-level — a couple of one-line integrations stitch your sync engine, runtime, and router together through it. The output: anallow(tenant) gate, a usage(tenant) rollup, and a fan-out to any sinks you've added.
import { createMeter, consoleSink } from '@absolutejs/metering';
const meter = createMeter({
sinks: [consoleSink, influxSink],
budgets: {
'*': { cpuMs: 60_000, requests: 10_000 }, // free-tier default
'acme-prod': { cpuMs: 600_000, requests: 1_000_000 }, // paid override
},
onBreach: ({ tenant, dimension, observed, limit }) => {
suspendAtRouter(tenant, { dimension, observed, limit });
},
});
// Wire @absolutejs/sync's handlerMetrics:
syncEngine.handlerMetrics = (record) => {
meter.record({
type: 'handler',
tenant: currentTenantId(),
mutationName: record.mutationName,
durationMs: record.durationMs,
cpuMs: record.cpuMs,
ok: record.ok,
});
};
// Wire @absolutejs/runtime's observation + transitions:
runtime.options.onMetrics = (event) => {
if (event.type !== 'observation') return;
meter.record({
type: 'observation',
tenant: event.key,
cpuMs: event.cpuMs,
rssBytes: event.rssBytes,
});
};
// Gate at the router:
if (!meter.allow(tenantId)) return new Response('Quota exceeded', { status: 429 });#Cumulative & Rolling-Window Budgets
Cumulative budgets stick until reset() — good for "you've used up your free-tier CPU-seconds this month." Rolling-window budgets re-close automatically as events drain — good for "errors in the last 5 minutes." Both can apply to the same tenant; the breaker trips if any rule fires.
// Cumulative budgets stick until reset(). Rolling-window budgets re-close
// automatically as events drain. Both can apply to the same tenant.
const meter = createMeter({
budgets: {
'acme': { cpuMs: 3_600_000 }, // 1 hour CPU/month, sticky
},
rollingBudgets: {
'*': [
{ dimension: 'errors', windowMs: 5 * 60_000, limit: 50 },
{ dimension: 'requests', windowMs: 1 * 60_000, limit: 1_000 },
],
},
});
// Customer-facing 'N requests left in this window' display:
const left = meter.rollingSum('acme', 'requests', 60_000);
// After a rolling breach, the breaker auto-clears once the window drains.
// No reset() needed — that's the difference from cumulative.#Flushable Sinks
A sink can be a function or an object with { ingest, flush?, close? }. On dispose() the meter awaits every sink's flush in order, then every close. A throwing flush is logged and swallowed; later sinks still flush. That posture is what batched adapters (Stripe Metered, Influx, ClickHouse) need to not drop the last few events on a shard shutdown.
// Sinks can be a function (0.0.1 shape) OR an object with optional
// flush() / close(). dispose() awaits flush, then close, in order across
// every sink. A throwing flush is logged + swallowed; later sinks still
// flush. That's what batched adapters (Stripe, Influx, ClickHouse) need
// to not drop the last few events on shard shutdown.
const meter = createMeter({
sinks: [
consoleSink,
{
ingest: (event) => stripeMeter.report(event),
flush: () => stripeMeter.flush(),
close: () => stripeMeter.close(),
},
],
});
// On shard shutdown:
await meter.dispose(); // every sink's flush(), then close()#Snapshot & Restore
Serializable point-in-time state. Write a snapshot to disk before a shard reboot and restore it after — the bill doesn't reset to zero. The snapshot captures usage, tripped state, rolling-window state, AND the last-observation cpuMs baseline so the next observation charges a delta (not the cumulative- since-process-start value).
// Survive shard restarts — the bill doesn't reset to zero.
const snap = meter.snapshot();
await Bun.write('/var/lib/meter/state.json', JSON.stringify(snap));
// After shard restart:
const restored = createMeter({ /* same config */ });
restored.restore(JSON.parse(await Bun.file('/var/lib/meter/state.json').text()));
// Captures usage, tripped state, rolling-window state, and the last
// observation cpuMs baseline so the next observation charges a delta
// (not the cumulative-since-process-start value).