@absolutejs/metering

中文文档
0.8.0发布于 2026/10/22BSL-1.1TypeScriptES Module构建来源已验证近 30 天下载 1844

Per-tenant cost-attribution + budget enforcement for multi-tenant runtimes. Consumes handlerMetrics from @absolutejs/sync and spawn/idle/exit events from @absolutejs/runtime; rolls up CPU-ms, bytes egress, hibernation-GB-seconds per tenant; trips a circui

原始使用文档
文档目录9 节
  1. Surface (0.2.0)
  2. Sinks
  3. Cumulative budgets
  4. Rolling-window budgets
  5. Observation accounting
  6. Hibernation accounting
  7. Snapshot + restore
  8. Architectural role
  9. License

@absolutejs/metering

Per-tenant cost-attribution + budget enforcement for multi-tenant Bun runtimes.

Built for PaaS providers that run many small Bun apps under one host. Consumes handlerMetrics from @absolutejs/sync and lifecycle events from @absolutejs/runtime, rolls them up per tenant, and trips a circuit breaker the moment any per-tenant budget dimension is exceeded. The library SB-6 layer between the runtime and the billing / observability pipeline downstream.

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 it into a sync engine: sync handlerMetrics records → meter.record(...)
syncEngine.handlerMetrics = (record) => {
  meter.record({
    type: "handler",
    tenant: currentTenantId(),
    mutationName: record.mutationName,
    durationMs: record.durationMs,
    cpuMs: record.cpuMs,
    heapBytes: record.heapBytes,
    ok: record.ok,
    errorName: record.errorName,
  });
};

// And a runtime: spawn/idle-kill/exit transitions → meter.record(...)
runtime.options.onTransition = (event) => {
  meter.record({
    type: "process",
    tenant: event.key,
    transition: event.type,
    durationMs: event.durationMs,
  });
};

// And @absolutejs/[email protected]'s Linux observation events:
runtime.options.onMetrics = (event) => {
  if (event.type === "observation") {
    meter.record({
      type: "observation",
      tenant: event.key,
      cpuMs: event.cpuMs,
      rssBytes: event.rssBytes,
      at: event.at,
    });
  }
};

// Record one terminal AI agent run. `costMicros` is caller-calculated so model
// pricing stays an operator policy instead of becoming stale library data.
meter.record({
  type: "ai",
  tenant: tenantId,
  requestId,
  provider: "anthropic",
  model: "claude-sonnet-4-6",
  inputTokens: finish.usage.inputTokens,
  outputTokens: finish.usage.outputTokens,
  cacheReadInputTokens: finish.usage.cacheReadInputTokens,
  cacheWriteInputTokens: finish.usage.cacheWriteInputTokens,
  durationMs: finish.durationMs,
  turns: finish.turns,
  costMicros,
  ok: finish.reason === "complete",
  stopReason: finish.reason,
});

// Inside a hosted workload, hand handler/request events to the authenticated
// host-local collector. The PaaS injects this endpoint, token, and per-process
// source id; no tenant id is accepted from application code.
import { createWorkloadMeterReporter } from "@absolutejs/metering/reporter";
import { workloadMeterElysia } from "@absolutejs/metering/elysia";

const reporter = createWorkloadMeterReporter({
  endpoint: process.env.ABSOLUTE_METER_ENDPOINT!,
  sourceId: process.env.ABSOLUTE_METER_SOURCE_ID!,
  token: process.env.ABSOLUTE_METER_TOKEN!,
});
const app = new Elysia().use(workloadMeterElysia({ reporter }));
syncEngine.handlerMetrics = reporter.handlerMetrics;

// In your request handler, gate on the meter:
if (!meter.allow(tenantId))
  return new Response("Quota exceeded", { status: 429 });

Surface (0.2.0)

APIPurpose
createMeter(options)Factory. Returns a Meter.
meter.record(event)Accept one MeterEvent — handler, process, observation, or ai. Updates the rollup, fans out to sinks, may trip the breaker.
meter.allow(tenant)Pre-flight gate. Returns false if any cumulative budget tripped, any rolling-window rule is currently over, or reset() hasn't been called after a sticky cumulative trip.
meter.usage(tenant)Snapshot of the rollup: cpuMs, processCpuMs, bytesEgress, hibernationGbSeconds, processRssBytesPeak, etc.
meter.rollingSum(tenant, dimension, windowMs)Current rolling-window total for (tenant, dimension, window). For customer-facing "you have N requests left in this window" displays.
meter.rollingFor(tenant)Active rolling rules.
meter.reset(tenant)Clear a cumulative-trip breaker without zeroing accumulated usage. Rolling-window trips auto-clear as events drain.
meter.clear(tenant)Zero accumulated usage AND clear the breaker.
meter.tenants()Every tenant seen so far.
meter.budget(tenant)Active cumulative budget.
meter.tripped(tenant)Re-evaluates rolling rules; calling it can untrip a tenant whose window has drained.
meter.snapshot() / restore(snap)Serializable point-in-time state. Survive shard restarts; the bill doesn't reset to zero.
meter.dispose()Await every sink's flush?, then close?.

Sinks

A MeterSink is either a function (event) => void | Promise<void> or an object { ingest, flush?, close? }. Sinks are fanned out in order. A throw or rejection from one sink does not stop later sinks — the meter is on the billing critical path. The error is logged to stderr; the recorder keeps going.

On dispose(), every object-shaped sink's flush() is awaited (serial across sinks), then every close() is awaited. A throwing flush is logged + swallowed; later sinks still flush. This is what batched adapters (Stripe, Influx, ClickHouse) need to not drop the last few events on shutdown.

Bundled: consoleSink. Adapters for Influx / Prometheus / Stripe ship later as sibling packages.

Cumulative budgets

budgets['*'] is the default; per-tenant entries override it. Any dimension hitting its limit trips the breaker; onBreach fires once per trip (call reset() to re-arm). Subsequent events still accumulate — the bill keeps growing even after the gate is closed, which matches how real billing works.

Dimensions: cpuMs, processCpuMs, bytesEgress, requests, errors, hibernationGbSeconds, aiRequests, aiInputTokens, aiOutputTokens, aiCostMicros.

Rolling-window budgets

createMeter({
  rollingBudgets: {
    "*": [
      { dimension: "errors", windowMs: 5 * 60_000, limit: 50 }, // 50 errors / 5 min trips the breaker
      { dimension: "requests", windowMs: 1 * 60_000, limit: 1_000 }, // 1k req / min rate cap
    ],
    "acme-prod": [
      { dimension: "cpuMs", windowMs: 60_000, limit: 50_000 }, // 50s sandbox CPU / minute
    ],
  },
});

A rolling-window rule trips when the rolling sum reaches limit. It re-closes automatically as events drain out of the window — no reset() needed. That's the difference from a cumulative budget, which sticks until reset(). Both kinds can be set on the same tenant; allow() is false if any rule trips.

Observation accounting

@absolutejs/[email protected] emits { type: 'observation', cpuMs, rssBytes } on a configurable interval. The meter treats cpuMs as CUMULATIVE since spawn and charges the delta since the previous observation. A process event of transition === 'spawn', 'adopt', 'restored', or 'exit' resets the baseline so a fresh or newly supervised process doesn't double-charge. Only a real spawn increments the spawn counter.

Hibernation accounting

@absolutejs/runtime emits idle-kill / lru-evict transitions; the metering caller is responsible for computing the GB-seconds the tenant racks up while hibernated and passing it as hibernationGbSeconds on the process event. The meter sums the values it sees — it does not infer them.

Snapshot + restore

const json = JSON.stringify(meter.snapshot());
await persistToDisk('/var/lib/meter/state.json', json);

// On shard restart:
const restored = createMeter({ ... same config ... });
restored.restore(JSON.parse(await readFromDisk('/var/lib/meter/state.json')));

The snapshot captures every tenant's usage, tripped state, rolling-window state, and the last observation cpuMs baseline so the next observation charges a sensible delta instead of jumping to the cumulative-since-process-start value.

Architectural role

  • @absolutejs/sync — emits handlerMetrics records on every sandboxed mutation.
  • @absolutejs/runtime — emits lifecycle events on every spawn / idle-kill / exit.
  • @absolutejs/metering — this library. Rolls those up per tenant + gates them.
  • @absolutejs/router (planned) — consumes meter.allow() to refuse traffic for over-quota tenants at the edge.

License

BSL 1.1 with a named carveout for the hosted multi-tenant metering / cost-attribution / per-tenant billing category (Stripe Metered Billing, Orb, Metronome, Lago, Amberflo, Cloudflare Workers billing, Convex usage dashboards, Vercel usage dashboards). See LICENSE. Change Date: 4 years from first release; Change License: Apache 2.0.

版本与发布记录

@absolutejs/metering 共发布 12 个版本,首次发布于 2026/05/30;过去 12 个月发布了 12 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.8.0latest2026/10/2217 天前
0.7.02026/08/202 个月前
0.6.12026/08/132 个月前
0.6.02026/07/313 个月前
0.5.02026/07/233 个月前
0.4.12026/07/163 个月前

关于 @absolutejs/metering 的常见问题

@absolutejs/metering 是什么?

Per-tenant cost-attribution + budget enforcement for multi-tenant runtimes. Consumes handlerMetrics from @absolutejs/sync and spawn/idle/exit events from @absolutejs/runtime; rolls up CPU-ms, bytes egress, hibernation-GB-seconds per tenant; trips a circui

如何安装 @absolutejs/metering?

在项目目录运行 npm install @absolutejs/metering,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:bun-types(^1.3.14)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@absolutejs/metering 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@absolutejs/metering 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@absolutejs/metering 可以商用吗?

@absolutejs/metering 使用 BSL-1.1 许可。声明的许可为 BSL-1.1,请阅读许可原文确认使用条件。以上为概括,不构成法律意见,请以许可原文为准。

@absolutejs/metering 的下载量有多少?

根据 npm 官方下载统计,@absolutejs/metering 在 2026/10/05 – 2026/11/03 这 30 天内被下载 1,844 次,日均约 61 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@absolutejs/metering 还在维护吗?

最新版本 0.8.0 发布于 2026/10/22(17 天前);至今共发布 12 个版本,其中过去 12 个月发布了 12 个;首次发布于 2026/05/30。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。