@usagebill/sdk
v0.4.0
Published
One-line wrapper around @supabase/supabase-js that tags every request per tenant — usage-based billing & per-tenant cost attribution for multi-tenant Supabase apps.
Maintainers
Readme
@usagebill/sdk
One line of code — and you can see which of your customers (tenants) drives which Supabase usage, and what to bill them for.
A thin wrapper around @supabase/supabase-js that tags every request with a tenantId and streams lightweight usage signals to usagebill — usage-based billing & per-tenant cost attribution for multi-tenant SaaS on Supabase.
Supabase reports usage per project, never per tenant. This SDK captures what you need to bill each customer — requests, rows, egress bytes, latency, status — by hooking the one seam Supabase officially supports: a custom fetch. So it sees every call (REST, RPC, Storage, Auth) without changing how you use supabase-js.
- One-line integration — same
createClient, one extra option. - Fire-and-forget — tracking never blocks or throws on your user's path. If usagebill is unreachable, your app is unaffected.
- Zero-PII by default — only path/table/operation/size metadata leaves your app. Never request/response bodies, headers, query values, IPs, or user IDs.
Install
npm install @usagebill/sdk
# pnpm add @usagebill/sdk · yarn add @usagebill/sdk@supabase/supabase-js v2 is a peer dependency (you already have it).
Quick start
import { createClient } from "@usagebill/sdk";
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
usagebill: {
apiKey: "ub_live_…", // from your usagebill dashboard
tenantId: () => getCurrentTenantId(),
},
});
// use `supabase` exactly as before — every call is now tagged & metered
const { data } = await supabase.from("invoices").select("*");Without the usagebill option, createClient is a pure passthrough to supabase-js.
Server-side: isolate the tenant per request
On a shared server client a plain tenantId string would mix concurrent requests → wrong invoices. Use the AsyncLocalStorage helper so each request keeps its own tenant:
import { createClient } from "@usagebill/sdk";
import { createTenantContext } from "@usagebill/sdk/server";
const tenants = createTenantContext();
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
usagebill: { apiKey: "ub_live_…", tenantId: tenants.current },
});
// wrap each request — ALS propagates the tenant through the async chain
app.use((req, res, next) => tenants.run(resolveTenant(req), () => next()));@usagebill/sdk/server is Node-only (it uses node:async_hooks).
With @supabase/ssr (Next.js App Router, the common setup)
If your app uses @supabase/ssr (createServerClient / createBrowserClient), don't swap the
import — inject usagebill's tracked fetch into the client you already create:
// utils/supabase/server.ts — a new client is created per request
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { createUsagebillFetch } from "@usagebill/sdk";
export async function createClient() {
const cookieStore = await cookies();
const tenant = await resolveTenant(cookieStore); // this request's tenant (JWT claim / session)
return createServerClient(SUPABASE_URL, SUPABASE_KEY, {
cookies: { /* getAll / setAll as usual */ },
global: {
fetch: createUsagebillFetch({
apiKey: process.env.USAGEBILL_API_KEY!,
tenantId: () => tenant,
}),
},
});
}// utils/supabase/client.ts — browser
import { createBrowserClient } from "@supabase/ssr";
import { createUsagebillFetch } from "@usagebill/sdk";
export const createClient = () =>
createBrowserClient(SUPABASE_URL, SUPABASE_KEY, {
global: {
fetch: createUsagebillFetch({
apiKey: process.env.NEXT_PUBLIC_USAGEBILL_API_KEY!, // client key is public by nature
tenantId: () => getCurrentTenantId(),
}),
},
});Why no AsyncLocalStorage here? With @supabase/ssr a fresh client is created per request, so
the tenant is resolved per request in the getter closure. createUsagebillFetch shares one batching
buffer per (endpoint, apiKey) under the hood, so calling it per request is cheap and correct. ALS
(createTenantContext from @usagebill/sdk/server) is only needed when you reuse one shared
singleton client across requests (e.g. a backend without ssr).
Edge runtimes (Vercel Edge Middleware, Cloudflare Workers): timers may not fire after the
response completes — pass the runtime's waitUntil and usagebill delivers each request's
events through it:
createUsagebillFetch({
apiKey: process.env.USAGEBILL_API_KEY!,
tenantId: () => tenant,
waitUntil: (p) => ctx.waitUntil(p), // Vercel middleware: event.waitUntil
});If a send fails, the events stay buffered and ride along with the next request's flush — but a batch whose send fails on the isolate's final request can still be lost. Delivery is best-effort with server-side dedup by event id (retries are never double-billed).
What gets captured
One UsageEvent per Supabase call:
| Field | Meaning |
|---|---|
| tenant | from your tenantId getter |
| project | optional static projectId tag |
| resource | rest · rpc · storage · auth · other |
| table / op | PostgREST table, RPC name, or storage bucket & select / insert / update / delete / rpc / HTTP method (storage) |
| status | HTTP status code |
| durationMs | request latency |
| bytes | response body size (egress proxy) |
| rows | row count (from the content-range header) |
| ts / id | timestamp + stable id (re-delivery is de-duplicated server-side) |
No request/response bodies, headers, query values, IPs, or user IDs are ever collected.
Options
These options are shared by both createClient (the usagebill: sub-object) and createUsagebillFetch:
| Option | Type | |
|---|---|---|
| apiKey | string | required — your usagebill ingest key (ub_live_…) |
| tenantId | () => string \| null | required — a getter (never a bare string), so server clients never mix requests |
| projectId | string? | optional static project tag |
| endpoint | string? | ingest endpoint; defaults to the usagebill cloud |
| waitUntil | ((p: Promise<unknown>) => void)? | edge runtimes only — pass the runtime's waitUntil ((p) => ctx.waitUntil(p)) so each request's events are delivered after the response (timers may not fire there) |
createUsagebillFetch(options)
Returns a tracked fetch function suitable for global.fetch injection. Accepts the same options as above. Buffers are shared per (endpoint, apiKey) — calling this once per request (as @supabase/ssr patterns require) is safe and cheap.
Changelog
0.4.0
waitUntiloption — reliable delivery on edge runtimes (Vercel Edge Middleware, Cloudflare Workers): pass the runtime'swaitUntiland each request's events are drained through it after the response. At-least-once with server-side dedup by event id; a batch whose send fails on the isolate's final request can still be lost.sendBatchkeepalive fallback: runtimes whosefetchrejects thekeepaliveoption (Cloudflare Workers) get one retry without it — events now deliver there.- English API docs: all public JSDoc (npm
.d.tshovers) translated to English.
0.3.0
createUsagebillFetch(options)— new export for@supabase/ssr/global.fetchinjection patterns; accepts the same options ascreateClient. Buffers are shared per(endpoint, apiKey)so calling this once per request is safe and cheap.
0.2.0
- Storage events now carry the bucket name in
table, parsed from the request path — bucket only, never object paths (zero-PII). Dashboard storage rows and Stripe meters can now target individual buckets (see per-bucket billing below). Calls whose bucket lives only in the request body (copy/move) keeptable: null. - Per-bucket billing: meters filtered on
tableonly match REST/RPC traffic by default (frozen pre-0.2.0 semantics — a bucket sharing a metered table's name is never counted into that meter by accident). To bill a bucket, opt in explicitly with{ "resource": "storage", "table": ["media"] }in the meter's filters;resourcealso combines withop. - Avoid bucket names that collide with storage endpoint keywords (
public,sign,authenticated,info,list,list-v2,upload,copy,move): such buckets mis-parse and may surface an object-path segment as the bucket name instead.
0.1.0
- Initial release.
