AbsoluteJS

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.

TS
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.

TS
// 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.

TS
// 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).

TS
// 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).