Tracing
@spondo/billing/tracing sends OpenTelemetry traces of your AI calls to Spondo Billing, tagged with the customer and product they were for. Spondo Billing prices each LLM call from its token counts, so you see what every customer costs you to serve, and what each signal cost. Trace AI costs explains how costs are worked out; this page is the reference for the module.
Install
Tracing runs on Node 20 or later. Install the client with the OpenTelemetry packages it uses, which are optional peer dependencies so that apps without tracing don't need them:
npm install @spondo/billing @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/exporter-trace-otlp-http
| Package | Version |
|---|---|
@opentelemetry/api | 1.10 or later |
@opentelemetry/sdk-trace | 3.x |
@opentelemetry/context-async-hooks | 3.x |
@opentelemetry/exporter-trace-otlp-http | 0.300 or later |
Spans only carry token counts when something records them, so also install an instrumentation for your LLM client: @arizeai/openinference-instrumentation-openai for OpenAI, @arizeai/openinference-instrumentation-anthropic for Anthropic, or @ai-sdk/otel for the Vercel AI SDK (version 7). Trace AI costs lists the options.
Example
Pick your LLM client: every example in these docs follows your choice.
import OpenAI from "openai";
import { OpenAIInstrumentation } from "@arizeai/openinference-instrumentation-openai";
import { initTracing, trace, signal } from "@spondo/billing/tracing";
initTracing(); // once, at startup; reads SPONDO_API_KEY
new OpenAIInstrumentation({ traceConfig: { hideInputs: true, hideOutputs: true } }).manuallyInstrument(OpenAI); // after initTracing()
const openai = new OpenAI();
export async function summarise(account: { id: string }, doc: { id: string; pages: number }) {
return trace({ externalCustomerId: account.id, externalProductId: "documind" }, async () => {
const summary = await openai.chat.completions.create({ model: "gpt-5.5", messages: [/* … */] }); // priced from its tokens
await signal("document_uploaded", { pages: doc.pages }, { idempotencyKey: doc.id }); // carries this trace's cost
return summary;
});
}
import Anthropic from "@anthropic-ai/sdk";
import { AnthropicInstrumentation } from "@arizeai/openinference-instrumentation-anthropic";
import { initTracing, trace, signal } from "@spondo/billing/tracing";
initTracing(); // once, at startup; reads SPONDO_API_KEY
new AnthropicInstrumentation({ traceConfig: { hideInputs: true, hideOutputs: true } }).manuallyInstrument(Anthropic); // after initTracing()
const anthropic = new Anthropic();
export async function summarise(account: { id: string }, doc: { id: string; pages: number }) {
return trace({ externalCustomerId: account.id, externalProductId: "documind" }, async () => {
const summary = await anthropic.messages.create({ model: "claude-sonnet-5-5", max_tokens: 1024, messages: [/* … */] }); // priced from its tokens
await signal("document_uploaded", { pages: doc.pages }, { idempotencyKey: doc.id }); // carries this trace's cost
return summary;
});
}
import { generateText, registerTelemetry } from "ai";
import { OpenTelemetry } from "@ai-sdk/otel";
import { openai } from "@ai-sdk/openai";
import { initTracing, trace, signal } from "@spondo/billing/tracing";
initTracing(); // once, at startup; reads SPONDO_API_KEY
registerTelemetry(new OpenTelemetry()); // after initTracing(): every AI SDK call records its model and tokens
export async function summarise(account: { id: string }, doc: { id: string; pages: number }) {
return trace({ externalCustomerId: account.id, externalProductId: "documind" }, async () => {
const { text } = await generateText({
model: openai("gpt-5.5"),
prompt: `Summarise this document: …`,
telemetry: { recordInputs: false, recordOutputs: false }, // keeps the prompt and answer off the span
});
await signal("document_uploaded", { pages: doc.pages }, { idempotencyKey: doc.id }); // carries this trace's cost
return text;
});
}
initTracing(options?)
Starts sending spans. Call it once, when your process starts, before the first trace().
It takes the client's options: apiKey (default SPONDO_API_KEY) and baseUrl (default SPONDO_BASE_URL, then https://billing.getspondo.com). Spans go to <baseUrl>/v1/traces with the key as a bearer token, in batches.
It registers a global OpenTelemetry tracer provider and Node's async context manager, which keeps trace()'s customer across awaits, and returns the provider. Use an org API key: trace ingest refuses customer keys.
trace(tags, fn)
Runs fn as one unit of work for a customer and returns what fn returns. Every span started inside it, including your LLM instrumentation's spans, carries the customer and product, so their cost is put on that customer.
| Tag | Required | What it is |
|---|---|---|
externalCustomerId | Yes | Your ID for the customer. Spondo Billing's ID (cus_…) and the customer's aliases work too. |
externalProductId | No | Your ID for the product the work was for, or Spondo Billing's (prod_…). |
trace() wraps fn in a span named spondo.trace. If fn throws, the span records the exception and an error status, and the error is thrown on to you.
signal(eventName, data?, options?)
Sends a signal for the enclosing trace()'s customer and product, and links it to the trace: the signal's data gets the trace's trace_id, so the costs traced in it become that signal's cost. Call it inside trace(), after initTracing(); anywhere else it throws.
| Argument | What it is |
|---|---|
eventName | What happened, such as document_uploaded. Plans price usage and credits by it. |
data | Numbers and details to keep with the signal. Plans can price by its numbers. |
options.idempotencyKey | A key that makes a resend safe: a signal sent twice with the same key is stored once. |
It returns the same { ingested, duplicates, failed } as signals.send.
flush()
Sends the spans still waiting in the batch. Spans are batched to keep requests few, so in a serverless function await flush() before returning, and in a script before it exits; otherwise the last spans can be lost.
export default async function handler(req: Request) {
const result = await trace({ externalCustomerId: accountId(req) }, () => answer(req));
await flush();
return Response.json(result);
}
Span attributes
trace() sets these on each span inside it. If you send spans another way, set them yourself; Trace AI costs lists every attribute Spondo Billing reads.
| Attribute | Value |
|---|---|
meter.customer_id | The externalCustomerId tag. |
meter.product_id | The externalProductId tag, when set. |
A span with no token counts can state its own cost in US dollars as meter.cost:
import { trace as otel } from "@opentelemetry/api";
await otel.getTracer("documind").startActiveSpan("ocr", async (span) => {
const text = await ocr(doc); // a paid API that isn't an LLM
span.setAttribute("meter.cost", 0.0125);
span.end();
return text;
});
Prompts and completions are never stored, even when your instrumentation records them on the span.