Usage metering
meterRecord what each tenant uses, read it back by hour, day or month, and send closed hours to your billing provider through pay.
meterFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
meter records what each tenant uses, idempotently and without slowing a request, so you can show a usage page, enforce a plan limit or bill by usage.
At a glance
meter:
driver: db
metrics:
api_calls: { unit: call, aggregate: sum }
active_users: { unit: person, aggregate: unique, of: user }
export:
to: pay
every: 1h
metrics: { api_calls: { meter: api_calls } }import { meter } from "mainsdk";
// counted once even if this step is retried
meter.record("audio_minutes", 2.5, { feature: "speech" });
const month = await meter.usage({ metric: "audio_minutes", window: "month" });lib/usage.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { meter } from "mainsdk";
describe("usage", () => {
it("counts a retried record once", async () => {
const app = createTestApp({ main: "./main.yaml" }); // metrics: api_calls { aggregate: sum }
await app.asTenant({ id: "tnt_a" }, async () => {
meter.record("api_calls", 1, { idempotencyKey: "req-1" });
meter.record("api_calls", 1, { idempotencyKey: "req-1" });
await meter.flush();
assert.equal((await meter.usage({ metric: "api_calls", window: "day" })).value, 1);
});
});
});Calls
meter.record(metric, value?, { feature?, apikey?, user?, at?, idempotencyKey? })meter.usage({ metric, window, tenant?, groupBy? })meter.flush()localMeter(o)meterIssues(raw, opts?)parseMeter(raw)meter.close()meter.erase(user)meter.exportDue(tenants)meter.exportUser(user)meter.pseudonym(user)meter.startExport(list)
Drivers
5 drivers. Select one to see its line in main.yaml.
meter:
driver: dbChange this one line to swap providers; your calls stay the same.Your database integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-METER-0001 | Checkmainsdk check, before deploy | A metric not declared under meter.metrics |
MAIN-METER-0006 | Checkmainsdk check, before deploy | meter is invalid: unknown aggregate, unique without of, an exported metric with no provider meter, keep under 35 days, or local in production |
MAIN-METER-0002 | RuntimeError responses and logs | A value that isn't finite, is negative for sum or count, or an at outside its window (400) |
MAIN-METER-0003 | RuntimeError responses and logs | The buffer was full while the store was down, so the oldest records were dropped (logged with the count) |
MAIN-METER-0004 | RuntimeError responses and logs | Usage was served from the last known value because the store can't be reached (stale: true) |
MAIN-METER-0005 | RuntimeError responses and logs | An export to pay failed for a tenant and hour; retried with the same identifier |
MAIN-METER-0007 | RuntimeError responses and logs | No meter store for the tenant's region; the record is refused, never written elsewhere |
MAIN-METER-0008 | RuntimeError responses and logs | A usage window is invalid: from after to, not on whole hours, or over 13 months (400) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| meter@1 contract and declared metrics | Phase 1 | 0.3 | meter#contract |
| Idempotent records per tenant | Phase 1 | 0.3 | meter#record |
| Usage by hour, day, month and range | Phase 1 | 0.3 | meter#usage |
| Local, db and redis drivers by region | Phase 1 | 0.3 | meter#drivers |
| Buffered records and stale usage when the store is down | Phase 1 | 0.3 | meter#fail-static |
| Export to pay for usage billing | Phase 1 | 0.3 | meter#export |
| Pseudonymised user ids on erasure | Phase 1 | 0.3 | meter#privacy |
| Usage source for plan limits | Phase 1 | 0.3 | meter#entitlements-source |
Quick start
meter:
driver: local
metrics:
api_calls: { unit: call, aggregate: sum }import { meter } from "mainsdk";
// call this once the request has succeeded; it returns { id, queued: true } at once
meter.record("api_calls", 1, { feature: "export" });
const usage = await meter.usage({ metric: "api_calls", window: "day" });
console.log(usage.value, usage.unit); // { metric, value, unit, from, to, stale }Configuration
meter:
driver: db # local | db | redis; default: the runtime store's driver
redis: # redis driver: one store per region
eu: { url: secret:REDIS_EU_URL }
metrics:
api_calls: { unit: call, aggregate: sum }
storage_gb: { unit: GB, aggregate: max }
active_users: { unit: person, aggregate: unique, of: user }
keep: 400d # raw records; at least 35d
flush: 1s # how often buffered records are written
buffer: 10000 # records held in memory while the store is down
export:
to: pay
every: 1h
late: 1h
metrics:
api_calls: { meter: api_calls } # the provider's meter name| Key | What it does |
|---|---|
driver | local keeps records in process, for tests and one-instance dev. db stores them in the tenant's region. redis uses one store per region. |
metrics.<name> | Declares a metric. aggregate is sum, count, max, last or unique; unique needs of: user, feature or apikey. |
keep | How long raw records are kept. Hourly totals last as long as the tenant. |
export | Sends closed hours to the pay driver's usage API. See below. |
A metric you have not declared throws MAIN-METER-0001. Every call runs inside a tenant when tenancy is on.
What you get
meter.record()returns at once and never throws because the store is down. A retried step, job or webhook records once.- Usage by hour, day, month or range, split by feature, API key or user, in the tenant's region.
- Closed hours go to your billing provider through
pay, with a total per tenant, metric and hour. A person's id in usage is pseudonymised when they are erased. - Reach for it when your pricing has an included allowance and a price per unit beyond it, such as API calls, audio minutes or storage. It runs in your app on your own store and is free.
Reference
| Call | What it does |
|---|---|
meter.pseudonym(user) | The keyed hash (u_ plus 24 hex characters) that stands for a user in totals and groups. Use it to look a person up in grouped usage. It needs the same secret on every instance |
meter.exportUser(user) | The raw records that name a person, for the person's data export |
meter.erase(user) | Replaces a person's id in raw records with its keyed hash, so totals and unique counts stay right. Returns { records }, the number rewritten |
meter.exportDue(tenants) | Sends every closed, unexported hour of every exported metric to the billing provider. Takes { id, region }[] and returns { sent, skipped, failed }. A tenant with no billing customer is skipped |
meter.startExport(list) | Runs exportDue every meter.export.every for the tenants list returns. It stops on close |
meter.close() | Stops the timers and tries to write what is buffered. Call it on shutdown |
localMeter({ now? }) | A meter in this process for tests: any metric counts as a sum and a record is visible to usage at once |
parseMeter(raw) | Reads and checks the meter section; MAIN-METER-0006 names every invalid key |
meterIssues(raw, { env? }) | The problems in a meter section as a list. With env: "prod", the local driver is one too, as mainsdk check reports it |
Examples
Meter AI requests per tenant
import { ai, meter } from "mainsdk";
export async function summarise(text: string, user: { id: string }) {
const summary = await ai.generate({
model: "anthropic/claude-sonnet",
prompt: `Summarise: ${text}`,
feature: "summary",
user,
});
meter.record("ai_summaries", 1, { feature: "summary", user: user.id });
return summary;
}Declare ai_summaries under meter.metrics first.
Read usage split by feature
import { meter } from "mainsdk";
const usage = await meter.usage({ metric: "api_calls", window: "month", groupBy: ["feature"] });
for (const g of usage.groups ?? []) console.log(g.key.feature, g.value);window is "hour", "day", "month" (UTC calendar windows) or { from, to } on whole hours, at most 13 months. Another tenant's usage needs tenant.system().
Count once when a step retries
Pass your own idempotencyKey when you have a natural one. Inside a workflow step, job or inbound webhook, the key defaults from that context. Outside any of them, with no key, nothing is deduplicated.
import { meter } from "mainsdk";
export function countMinutes(jobId: string, minutes: number) {
meter.record("audio_minutes", minutes, { idempotencyKey: `transcribe:${jobId}` });
}Duplicates are dropped when the batch is written (every flush), so a total read in the same second can briefly count a repeat.
Send usage to your billing provider
With meter.export, a scheduled job sends each tenant's total for every closed hour to the pay driver, with the identifier <tenant>:<metric>:<hour> so a retry counts once. Only totals leave the region: no user ids, API key ids or features.
import { pay } from "mainsdk";
// the same call the export job makes; the identifier makes a retry count once
await pay.reportUsage({
customer: "cus_123", // the provider's customer id
meter: "api_calls", // the provider's meter name
value: 1200,
at: Date.UTC(2026, 9, 27, 10),
identifier: "tnt_acme:api_calls:2026-10-27T10",
});Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-METER-0001 | A metric is not declared under meter.metrics | Declare it |
MAIN-METER-0002 | A value is not a finite number, is negative for sum or count, or at is out of range (400) | Pass a valid value |
MAIN-METER-0003 | The buffer was full, so the oldest records were dropped (logged) | Raise buffer, or fix the store |
MAIN-METER-0004 | Usage was served from the last known value because the store is unreachable (stale: true) | Restore the store |
MAIN-METER-0005 | An export to pay failed for a tenant and hour; it is retried (logged) | Check the pay driver |
MAIN-METER-0006 | meter is invalid, or local is used in production | Fix the key the error names; use db or redis |
MAIN-METER-0007 | No store for the tenant's region; the record is refused | Add meter.redis.<region> |
MAIN-METER-0008 | A usage window is invalid (400) | Use whole hours, from before to, at most 13 months |
mainsdk check reports MAIN-METER-0001 for a literal metric name in code that is not declared, and MAIN-METER-0006 for local with --env prod. See check codes.
Swapping drivers
meter:
driver: redis # was db
redis:
eu: { url: secret:REDIS_EU_URL }Your meter.record() calls stay the same. Past usage lives in the old store, so moving drivers is a data migration, not a one-line switch. Billing customers and past invoices live in your provider. See Swap a driver.
Testing
createTestApp puts meter on its in-memory driver with a fake clock. Every call runs inside a tenant, so wrap it in app.asTenant(). meter.flush() writes the buffer, so a total read after it is exact.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { meter } from "mainsdk";
describe("usage", () => {
it("counts a retried record once", async () => {
const app = createTestApp({ main: "./main.yaml" }); // metrics: api_calls { aggregate: sum }
await app.asTenant({ id: "tnt_a" }, async () => {
meter.record("api_calls", 1, { idempotencyKey: "req-1" });
meter.record("api_calls", 1, { idempotencyKey: "req-1" });
await meter.flush();
assert.equal((await meter.usage({ metric: "api_calls", window: "day" })).value, 1);
});
});
});Security and privacy
- The
userdimension is personal data. When a person is erased, it is replaced in raw records by a keyed hash, so totals anduniquecounts stay right. - Records stay in the tenant's region, and a record is never written to another region.
meteris for your app's billing. It is separate from how MainSDK Cloud counts its own usage; see Free in the app, paid in the Cloud.
FAQ
Does meter charge the customer? No. It records usage; your billing provider prices and charges it, through pay.
How is this different from analytics? meter holds exact, idempotent billing facts. Analytics events are product signals and are approximate.
Can a plan limit read the meter? Yes. A limit whose source is meter:<metric> in entitlements reads meter.usage.
Next
Was this page helpful?