The VP of Engineering at a B2B vertical SaaS company needs to bill each SMB customer for the AI features they use. Without per-tenant cost tracking, the company absorbs all LLM expenses, eroding margins. Existing observability tools don't tie token usage to tenant IDs, making chargeback impossible. The team manually approximates costs, leading to billing disputes and lost revenue.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
Every LLM call your B2B SaaS platform makes has a cost, and if you’re absorbing those costs across all tenants, your margins are taking a hit. This recipe shows you how to build a per-tenant LLM cost tracking and chargeback system that attributes each API call to the right tenant, computes real-time spend metrics, and exports cost data to your billing infrastructure. You’ll wire six REAA packages — SpendTracker, OTel bridge, cost telemetry, exporters, OTel cost pipeline, and multi-tenant accounting — into a Next.js 16 App Router application with a REST API and a simple dashboard. By the end, every LLM call is tracked per-tenant, and you can answer “how much did acme-corp cost us this month?” in milliseconds.
Prerequisites
Node.js 22+ and pnpm installed on your machine
A terminal and a code editor
Familiarity with TypeScript, Next.js App Router conventions, and basic OpenTelemetry concepts
No API keys needed — this recipe runs entirely in-memory and connects to external exporters (CloudWatch, Phoenix/Loki) only when you configure them
Step 1: Scaffold the project and install dependencies
Start with a fresh Next.js App Router project. The scaffolded files are already in place — package.json, tsconfig.json, next.config.ts, vitest.config.ts, and app/layout.tsx. Your first task is to install the dependencies.
Open package.json and confirm the dependencies block includes these exact version-pinned entries:
Expected output: pnpm resolves and installs all dependencies into node_modules/ without errors. You now have the six REAA packages — the SpendTracker for circular-buffer spend recording, the OTel bridge for converting GenAI span attributes into spend entries, the cost telemetry foundation for shared types and utilities, the exporters for pushing data to CloudWatch and Phoenix/Loki, the OTel cost exporter for extracting cost from OTel spans, and the multi-tenant accounting package for per-call/per-token pricing with tiered discounts.
Step 2: Configure environment variables
All configuration flows through environment variables. Create or update .env.example with the entries the recipe needs:
env
# Env vars used by agnostic-per-tenant-llm-cost-chargeback.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentSPEND_STORE_MAX_ENTRIES=500000DEFAULT_TENANT_DAILY_LIMIT=100.0TENANT_BUDGETS={"acme-corp": 50.0}EXPORTER_CLOUDWATCH_ENABLED=falseEXPORTER_CLOUDWATCH_REGION=us-east-1EXPORTER_CLOUDWATCH_NAMESPACE=LLM/CostsEXPORTER_PHOENIX_ENABLED=falseEXPORTER_PHOENIX_HOST=http://loki:3100OTEL_SERVICE_NAME=llm-cost-telemetryOTEL_COST_METRICS_PREFIX=llm_cost
SPEND_STORE_MAX_ENTRIES controls the circular-buffer capacity — when full, the oldest entries are evicted automatically. DEFAULT_TENANT_DAILY_LIMIT sets the fallback daily spend cap per tenant in USD. TENANT_BUDGETS is an optional JSON map for per-tenant overrides. The EXPORTER_* variables toggle and configure the CloudWatch and Phoenix/Loki exporters. The OTEL_* variables set the OpenTelemetry service name and metrics prefix.
Copy this to .env.local to use during development:
terminal
cp .env.example .env.local
Expected output: The file .env.local now contains all the environment variables with default placeholder values. No real credentials are committed — .env.example is what you check into version control.
Step 3: Create shared constants
Start the source code with a constants module. These values are used across the services and give you a single place to tune defaults.
Expected output: A small constants module with no side effects. Each constant is a well-named default that the services fall back to when environment variables aren’t set.
Step 4: Build the configuration service
The ConfigService reads environment variables and returns typed configuration objects.
Create src/services/ConfigService.ts:
ts
export interface AppConfig { maxEntries: number; defaultDailyLimit: number;}export function getAppConfig(): AppConfig { return { maxEntries: Number(process.env.SPEND_STORE_MAX_ENTRIES) || 500_000, defaultDailyLimit: Number(process.env.DEFAULT_TENANT_DAILY_LIMIT) || 100.0, };}export function getTenantBudgets(): Record<string, number> { try { const raw = process.env.TENANT_BUDGETS; if (!raw) return {}; const parsed = JSON.parse(raw) as Record<string, number>; for (const [key, val] of Object.entries(parsed)) { if (typeof val !== "number" || val <= 0) { throw new Error(`Invalid budget for tenant "${key}": ${String(val)}`); } } return parsed; } catch { return {}; }}
getAppConfig() reads SPEND_STORE_MAX_ENTRIES and DEFAULT_TENANT_DAILY_LIMIT from the environment, falling back to the constants you defined. getTenantBudgets() parses the TENANT_BUDGETS JSON string — if the JSON is invalid or missing, it returns an empty object.
Expected output:getAppConfig() returns { maxEntries: 500000, defaultDailyLimit: 100 } when environment variables are unset. getTenantBudgets() returns {} for invalid JSON.
Step 5: Create the pricing table and cost helpers
The cost helpers module defines a pricing table for popular LLM models and exposes functions to calculate costs, format amounts, and aggregate by model. It uses calculateCostFromTokens() from @reaatech/llm-cost-telemetry.
The pricing table stores USD per 1M tokens for input and output. calculateCost() looks up the model, computes (tokens / 1,000,000) * pricePerMillion for both input and output, and sums them. Unknown models fall back to $2.00 per 1M total tokens — a safe default that prevents gaps in cost tracking.
Expected output:calculateCost("gpt-5.2", 1000, 500) returns 0.0075. formatCostUSD(0.0045) returns "$0.0045". Unknown models use the fallback price.
Step 6: Build the tenant ID validator
Before any tenant ID reaches the SpendStore, validate it. This keeps the data clean and prevents injection-like issues.
Create src/lib/tenant-id-validator.ts:
ts
import { sanitizeLabel } from "@reaatech/llm-cost-telemetry";import { MAX_TENANT_ID_LENGTH } from "./constants.js";export function validateTenantId(raw: unknown): string { if (typeof raw !== "string" || raw.trim().length === 0) { throw new Error("tenantId must be a non-empty string"); } const trimmed = raw.trim(); if (trimmed.length > MAX_TENANT_ID_LENGTH) { throw new Error("tenantId exceeds 128 characters"); } if (!/^[a-zA-Z0-9\-]+$/.test(trimmed)) { throw new Error("tenantId contains invalid characters"); } return trimmed;}export function sanitizeTenantId(raw: string): string { return sanitizeLabel(raw);}
The validator enforces three rules: non-empty, max 128 characters, and alphanumeric plus hyphens only. The sanitizeTenantId wrapper uses the telemetry package’s sanitizeLabel() for cases where you need to normalize user-provided IDs for metric labels.
The SpendStore from @reaatech/agent-budget-spend-tracker is your real-time spend recording engine — a circular buffer with O(1) per-tenant lookups, sliding-window rate calculations, cost projections, and spike detection.
Create src/services/SpendTrackerService.ts:
ts
import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { generateId, now } from "@reaatech/llm-cost-telemetry";import { BudgetScope, type SpendEntry } from "@reaatech/agent-budget-types";export interface SpendEntryParams { tenantId: string; cost: number; inputTokens: number; outputTokens: number; modelId: string; provider: string;}export function createSpendTracker(maxEntries: number): SpendStore { return new SpendStore({ maxEntries });}export function recordSpendEntry(store: SpendStore, params: SpendEntryParams): number { return store.record({ requestId: generateId(), scopeType: BudgetScope.User, scopeKey: params.tenantId, cost: params.cost, inputTokens: params.inputTokens, outputTokens: params.outputTokens, modelId: params.modelId, provider: params.provider, timestamp: now(), });}export function getTenantSpend(store: SpendStore, tenantId: string): number { return store.getSpend(BudgetScope.User, tenantId);}export function getTenantSpendRate(store: SpendStore, tenantId: string, windowMinutes: number = 5): number { return store.getRate(BudgetScope.User, tenantId, windowMinutes);}export function getTenantProjection(store: SpendStore, tenantId: string, windowHours: number = 24): number { return store.projectTotal(BudgetScope.User, tenantId, windowHours);}export function detectTenantSpikes(store: SpendStore, tenantId: string, thresholdStdDev: number = 2): Array<{ entryId: number; cost: number; expectedCost: number; deviation: number; timestamp: Date }> { return store.detectSpikes(BudgetScope.User, tenantId, 5, thresholdStdDev);}export function getTenantEntries(store: SpendStore, tenantId: string, start: Date, end: Date): SpendEntry[] { return store.getEntriesInRange(start, end, BudgetScope.User, tenantId);}export function getAllTrackedTenants(store: SpendStore): string[] { return store.getAllScopes(BudgetScope.User).map(s => s.scopeKey);}
Every function wraps a SpendStore method, passing BudgetScope.User as the scope type and the tenant ID as the scope key. The recordSpendEntry function generates a unique request ID and records the cost, tokens, model, provider, and timestamp in a single atomic call.
Expected output: Recording 3 entries for "acme-corp" at $0.01 each means getTenantSpend(store, "acme-corp") returns 0.03.
Step 8: Create the OTel bridge service
The SpanListener from @reaatech/agent-budget-otel-bridge converts OpenTelemetry GenAI span attributes into spend entries. You feed it span attributes — gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, llm.cost.total_usd, tenant.id — and it delegates to your SpendStore through a controller shim.
Create src/services/OtelBridgeService.ts:
ts
import { SpanListener } from "@reaatech/agent-budget-otel-bridge";import { BudgetScope } from "@reaatech/agent-budget-types";import { BudgetController } from "@reaatech/agent-budget-engine";import type { SpendStore } from "@reaatech/agent-budget-spend-tracker";function createControllerShim(spendStore: SpendStore): BudgetController { return new BudgetController({ spendTracker: spendStore });}export function createSpanListener(spendStore: SpendStore): SpanListener { const controller = createControllerShim(spendStore); return new SpanListener({ controller });}export function createTenantScopeExtractor(): (attrs: Record<string, unknown>) => { scopeType: BudgetScope; scopeKey: string } | null { return (attrs: Record<string, unknown>) => ({ scopeType: BudgetScope.User, scopeKey: typeof attrs["tenant.id"] === "string" ? attrs["tenant.id"] : "unknown", });}export function processSpan(listener: SpanListener, attributes: Record<string, unknown>, overrides?: Record<string, unknown>): void { listener.onSpanEnd(attributes, overrides);}
The controller shim wraps the SpendStore so the SpanListener can route spend recording through it. The createTenantScopeExtractor maps a span’s tenant.id attribute to the BudgetScope.User scope — this is how the bridge knows which tenant to charge.
Expected output: Create a SpendStore and pass it to createSpanListener. Feed span attributes { "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 50, "llm.cost.total_usd": 0.015, "tenant.id": "acme-corp" } through processSpan and the SpendStore now has an entry for "acme-corp" with cost 0.015.
Step 9: Build the cost export service
The exporters from @reaatech/llm-cost-telemetry-exporters push spend data to external observability platforms. This service wraps CloudWatchExporter and PhoenixExporter, providing a uniform interface for exporting spans, records, health checks, and graceful shutdown.
Each export function loops through the exporter array and only calls enabled exporters. Errors in one exporter don’t stop the others — exportSpans catches exceptions and records a failure result, then continues.
Expected output:exportSpans([enabledExporter, disabledExporter], spans) calls only the enabled exporter. checkHealth([]) returns an empty object.
Step 10: Create the tenant cost accounting service
The multi-tenant accounting package provides per-call and per-token pricing with tiered volume discounts. This service wraps DefaultCostCalculator, InMemoryCostTracker, and CallbackUsageEmitter.
import { DefaultCostCalculator, InMemoryCostTracker, CallbackUsageEmitter, type CostAccount,} from "@reaatech/multi-tenant-mcp-cost-accounting";export interface PricingConfig { perCall?: Record<string, number>; perToken?: { input: number; output: number }; tiers?: Array<{ upTo: number; discount: number }>;}export interface UsageEvent { tenantId: string; itemName: string; itemType: "tool" | "resource" | "prompt"; inputTokens: number; outputTokens: number; timestamp: Date;}let _calculator: DefaultCostCalculator | null = null;export function createCostAccountingService(pricingConfig: PricingConfig) { const calculator = new DefaultCostCalculator({ perCall: pricingConfig.perCall ?? {}, perToken: pricingConfig.perToken ?? { input: 0, output: 0 }, tiers: pricingConfig.tiers ?? [{ upTo: Number.POSITIVE_INFINITY, discount: 0 }], }); _calculator = calculator; const tracker = new InMemoryCostTracker({ calculator }); const emitter = new CallbackUsageEmitter(async () => {}); return { calculator, tracker, emitter };}export function calculateAndRecord(tracker: InMemoryCostTracker, event: UsageEvent): number { const account = tracker.getAccount(event.tenantId); if (!_calculator) throw new Error("createCostAccountingService must be called before calculateAndRecord"); const cost = _calculator.calculate(event, account); tracker.record(event); return cost;}export function getTenantCostAccount(tracker: InMemoryCostTracker, tenantId: string): CostAccount { return tracker.getAccount(tenantId);}
The DefaultCostCalculator evaluates pricing in three layers: per-call (flat fee per item name), per-token (input and output token rates), and tiered discounts (volume-based percentage discounts). The InMemoryCostTracker accumulates per-tenant CostAccount records with total cost, total calls, and total token counts.
Expected output: With perToken: { input: 0.001, output: 0.002 }, a call using 1500 input and 800 output tokens returns a cost of (1500/1e6 * 0.001 + 800/1e6 * 0.002).
Step 11: Build the OTel cost processor service
The @reaatech/otel-cost-exporter provides OTel-native cost processing. This service wires it into a reusable pipeline that converts OTel ReadableSpan objects into cost calculations.
Create src/services/OtelCostProcessorService.ts:
ts
import { metrics } from "@opentelemetry/api";import { loadConfig as loadOtelCostConfig, createProcessorFactory, createMetricsBuilder, spanToCostSpan } from "@reaatech/otel-cost-exporter";import type { ReadableSpan } from "@opentelemetry/sdk-trace-base";export type CostPipeline = { processor: { processSpan(span: unknown): unknown }; meter: ReturnType<typeof metrics.getMeter>; builder: ReturnType<typeof createMetricsBuilder>;};export async function createCostPipeline(): Promise<CostPipeline> { const config = await loadOtelCostConfig(); const factory = createProcessorFactory(config); const processor = await factory.createProcessor(); const meter = metrics.getMeter("otel-cost-exporter"); const builder = createMetricsBuilder(meter, config.metrics.prefix); return { processor, meter, builder };}export function processSpan( processor: { processSpan(span: unknown): unknown }, otelSpan: ReadableSpan,): unknown { const costSpan = spanToCostSpan(otelSpan); if (!costSpan) return null; return processor.processSpan(costSpan);}
createCostPipeline() initializes the full pipeline asynchronously — loadOtelCostConfig() reads from environment, createProcessorFactory builds pricing tables and calculators, and createMetricsBuilder wires up OTel metric instruments. processSpan() converts a ReadableSpan to a CostSpan via spanToCostSpan() and feeds it to the processor.
Expected output: When a ReadableSpan has GenAI attributes (gen_ai.usage.input_tokens, etc.), spanToCostSpan() returns a CostSpan and processing produces a cost result. Non-GenAI spans return null.
Step 12: Wire the ChargebackService
The ChargebackService is the top-level orchestrator. It brings together the SpendTracker, the cost accounting tracker, and the exporters into a single class that handles ingestion, reporting, and export.
ingestLLMCall() records in both systems simultaneously — the SpendStore gets the raw cost for fast queries, and the cost tracker gets the detailed usage event for per-call/per-token accounting. Negative costs are clamped to zero. getChargebackReport() aggregates entries by model and returns total cost, breakdown, and count.
The REST API exposes seven endpoints for querying tenant spend, detecting spikes, fetching raw entries, exporting data, and checking health. Each route uses a module-level SpendStore instance and delegates to service functions.
Create app/api/tenants/route.ts — lists all tracked tenants:
ts
import { NextResponse } from "next/server";import { createSpendTracker, getTenantSpend, getAllTrackedTenants } from "../../../src/services/SpendTrackerService.js";import { getAppConfig } from "../../../src/services/ConfigService.js";const store = createSpendTracker(getAppConfig().maxEntries);export function GET(): Response { const tenants = getAllTrackedTenants(store); const result = tenants.map((id: string) => ({ tenantId: id, totalSpend: getTenantSpend(store, id), })); return NextResponse.json({ tenants: result, count: result.length });}
Create app/api/tenants/[tenantId]/route.ts — detailed spend for one tenant:
ts
import { NextRequest, NextResponse } from "next/server";import { createSpendTracker, getTenantSpend, getTenantSpendRate, getTenantProjection } from "../../../../src/services/SpendTrackerService.js";import { getAppConfig } from "../../../../src/services/ConfigService.js";import { validateTenantId } from "../../../../src/lib/tenant-id-validator.js";import { DEFAULT_CURRENCY } from "../../../../src/lib/constants.js";const store = createSpendTracker(getAppConfig().maxEntries);export async function GET( _req: NextRequest, { params }: { params: Promise<{ tenantId: string }> },): Promise<Response> { let tenantId: string; try { const { tenantId: raw } = await params; tenantId = validateTenantId(raw); } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 400 }); } return NextResponse.json({ tenantId, totalSpend: getTenantSpend(store, tenantId), spendRate: getTenantSpendRate(store, tenantId), projection: getTenantProjection(store, tenantId), currency: DEFAULT_CURRENCY, });}
import { NextRequest, NextResponse } from "next/server";import { createSpendTracker, getTenantEntries } from "../../../../src/services/SpendTrackerService.js";import { exportRecords } from "../../../../src/services/CostExportService.js";import { getAppConfig } from "../../../../src/services/ConfigService.js";import { validateTenantId } from "../../../../src/lib/tenant-id-validator.js";const store = createSpendTracker(getAppConfig().maxEntries);export async function POST(req: NextRequest): Promise<Response> { let body: Record<string, unknown>; try { body = await req.json() as Record<string, unknown>; } catch { return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); } const rawTenantId = body.tenantId; let tenantId: string; try { tenantId = validateTenantId(rawTenantId); } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 400 }); } const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const entries = getTenantEntries(store, tenantId, startOfMonth, now); try { await exportRecords([], entries); } catch { // no exporters configured, just return count } return NextResponse.json({ exported: true, recordCount: entries.length });}
Create app/api/health/route.ts — health check:
ts
import { NextResponse } from "next/server";import { checkHealth } from "../../../src/services/CostExportService.js";export async function GET(): Promise<Response> { const exportersHealth = await checkHealth([]); return NextResponse.json({ status: "ok", uptime: process.uptime(), exporters: exportersHealth, });}
Expected output: After recording entries, GET /api/tenants returns { tenants: [...], count: N }. GET /api/tenants/acme-corp returns spend data. GET /api/health returns { status: "ok", uptime: ..., exporters: {} }.
Each route handler follows Next.js App Router conventions — named exports for each HTTP verb (GET, POST), NextRequest/NextResponse types, and async params for dynamic routes (Next 15+ pattern). Validation uses the tenant ID validator, and all errors return structured JSON with appropriate HTTP status codes.
Step 14: Understand the instrumentation hook
The src/instrumentation.ts file provides a startup hook that initializes services when the Node.js server starts. It guards on NEXT_RUNTIME === "nodejs" so it only runs in the server runtime. Dynamic imports prevent Node-only modules from breaking the edge runtime at module-load time.
In this recipe, the register() function is provided for reference — the route handlers create their own SpendStore instances at module level, so the app works without the hook. To activate instrumentation, add experimental.instrumentationHook: true to next.config.ts:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, },};export default nextConfig;
When enabled, register() fires on next dev startup. If NEXT_RUNTIME is "nodejs", it creates the SpendTracker and attempts to initialize the OTel cost pipeline. If pipeline init fails (no OTel config), the error is caught silently — the rest of the app still works.
Expected output: With instrumentationHook enabled, next dev starts and register() fires. The OTel pipeline initialises asynchronously, and any init failure is caught without affecting the server.
Step 15: Update the dashboard page
The home page fetches tenant data and health status server-side and renders them in a simple table.
The page is an async server component that fetches from the internal API routes with cache: "no-store" for real-time data. If a fetch fails, it falls back to empty/unreachable state.
Expected output: Visit http://localhost:3000 and see “LLM Cost Chargeback Dashboard” with health status, uptime, and a tenants table. When no data exists, you see “No tenants tracked yet.”
Step 16: Write tests and verify
The recipe ships with a test suite covering services, lib functions, route handlers, and integration flows. Tests use vi.mock() to mock REAA package modules so no real external calls are made.
Here’s the test for src/lib/cost-helpers.ts at tests/lib/cost-helpers.test.ts:
And the route test for the tenant detail endpoint at tests/routes/tenant-detail.test.ts:
ts
import { describe, it, expect, vi } from "vitest";import { NextRequest } from "next/server";vi.mock("../../src/services/SpendTrackerService.js", () => ({ createSpendTracker: vi.fn().mockReturnValue({}), getTenantSpend: vi.fn().mockReturnValue(0.05), getTenantSpendRate: vi.fn().mockReturnValue(0.01), getTenantProjection: vi.fn().mockReturnValue(0.12),}));vi.mock("../../src/services/ConfigService.js", () => ({ getAppConfig: vi.fn().mockReturnValue({ maxEntries: 1000, defaultDailyLimit: 100 }),}));import { GET } from "../../app/api/tenants/[tenantId]/route.js";describe("GET /api/tenants/:tenantId", () => { it("returns spend data for known tenant", async () => { const req = new NextRequest(new Request("http://localhost/api/tenants/acme-corp")); const res = await GET(req, { params: Promise.resolve({ tenantId: "acme-corp" }) }); const body = await res.json() as { tenantId: string; totalSpend: number; spendRate: number; projection: number }; expect(res.status).toBe(200); expect(body.tenantId).toBe("acme-corp"); expect(body.totalSpend).toBe(0.05); expect(body.spendRate).toBe(0.01); expect(body.projection).toBe(0.12); }); it("returns 400 for empty tenantId", async () => { const req = new NextRequest(new Request("http://localhost/api/tenants/")); const res = await GET(req, { params: Promise.resolve({ tenantId: "" }) }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toBe("tenantId must be a non-empty string"); });});
Run the full suite:
terminal
pnpm test
Expected output: 74 tests pass across 36 test suites. All four coverage metrics (lines, branches, functions, statements) meet or exceed 90%. Run pnpm typecheck for zero type errors and pnpm lint for zero lint warnings.
Next steps
Add a real OTel exporter — wire CloudWatchExporter with real AWS credentials or point PhoenixExporter to a running Loki instance. Set EXPORTER_CLOUDWATCH_ENABLED=true or EXPORTER_PHOENIX_ENABLED=true in your environment to start pushing cost data to observability.
Extend the pricing table — add entries for new models as they launch. The PRICING_TABLE in src/lib/cost-helpers.ts is a ReadonlyMap you can append to; the unknown-model fallback ensures zero gaps.
Replace the module-level stores — the route handlers create a SpendStore at module level for simplicity. In production, export stores from a shared module and import them, or use the instrumentation hook with a dependency injection container.
Add budget enforcement — the BudgetController from the agent-budget-engine package can enforce per-tenant hard caps. Call controller.defineBudget() with limits and policies, then check controller.checkBudget() before each LLM call to reject or warn when a tenant exceeds its budget.
Build a billing integration — replace the no-op CallbackUsageEmitter callback with a real webhook POST to your Stripe, Chargebee, or custom billing service. The exportChargebackData method already bundles monthly entries for export.