Trace AI costs

Signals tell Spondo Billing what your product did for each customer. Traces tell it what that cost you. Send OpenTelemetry traces of your LLM calls and Spondo Billing prices each call from its model and token counts, puts the cost on the customer it was for, and links it to the signal it produced. Together with revenue, that gives you margin by customer, product, signal, vendor and model.

There are two ways to send traces:

  • Node: the SDK's @spondo/billing/tracing sets everything up and tags spans with the customer for you.
  • Any language: point any OpenTelemetry SDK's OTLP exporter at https://billing.getspondo.com/v1/traces and set two attributes yourself.

How a cost is worked out

Spondo Billing keeps the spans that are AI calls: those that carry token usage or a stated cost, or name a model or an AI operation. It drops the rest (HTTP, database and framework spans). When a span with usage wraps another span with usage, as LangChain's chat model spans wrap the provider call inside them, only the innermost counts, so nothing is counted twice. For each span it keeps:

WhatRead from
Customermeter.customer_id: your ID for the customer, Spondo Billing's cus_… ID or one of the customer's aliases.
Productmeter.product_id: your ID for the product or Spondo Billing's prod_… ID.
Modelgen_ai.response.model, else gen_ai.request.model.
Tokensgen_ai.usage.input_tokens, gen_ai.usage.output_tokens and the cached input tokens.
Stated costmeter.cost, in US dollars.

A span that states meter.cost costs exactly that. Otherwise the cost is the model's price for its input, output and cached tokens: your own rate if you set one on the dashboard's Developers page under Model prices, else the model's list price, which Spondo Billing updates daily. Cached input tokens are charged at the model's cache-read rate where it has one. Model names are matched loosely, so a provider-prefixed or dated name (anthropic/claude-sonnet-5-5) finds its model's price. A span whose model has no price is stored with a cost of zero and marked unpriced on the Cost traces page, so you can add a rate.

Anthropic reports cache reads and writes separately from input_tokens; Spondo Billing adds them back, so input tokens mean the same for every vendor.

Prompts and completions are never stored, even when your instrumentation records them.

Costs and signals

A trace's cost belongs to a signal when the signal carries the trace's ID in data.trace_id. The SDK's signal() adds it for you. The link works both ways:

  • A span with no customer attribute takes the customer and product of the signal sent with its trace ID.
  • Each signal shows what it cost you, and the Overview shows margin per signal.

So if tagging every span is awkward, it is enough to send each signal with the trace_id of the work that produced it.

From Node

Install the client, the OpenTelemetry packages it uses, and the instrumentation for your LLM client. Then call initTracing() once at startup, instrument your LLM client, and wrap each unit of work for a customer in trace(). Pick your client: every example in these docs follows your choice.

npm install @spondo/billing @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/exporter-trace-otlp-http @arizeai/openinference-instrumentation-openai
import OpenAI from "openai";
import { OpenAIInstrumentation } from "@arizeai/openinference-instrumentation-openai";
import { initTracing, trace, signal } from "@spondo/billing/tracing";

initTracing(); // reads SPONDO_API_KEY
new OpenAIInstrumentation({ traceConfig: { hideInputs: true, hideOutputs: true } }).manuallyInstrument(OpenAI);

const openai = new OpenAI();

await 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 }); // carries this trace's cost
});
npm install @spondo/billing @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/exporter-trace-otlp-http @arizeai/openinference-instrumentation-anthropic
import Anthropic from "@anthropic-ai/sdk";
import { AnthropicInstrumentation } from "@arizeai/openinference-instrumentation-anthropic";
import { initTracing, trace, signal } from "@spondo/billing/tracing";

initTracing(); // reads SPONDO_API_KEY
new AnthropicInstrumentation({ traceConfig: { hideInputs: true, hideOutputs: true } }).manuallyInstrument(Anthropic);

const anthropic = new Anthropic();

await 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 }); // carries this trace's cost
});
npm install @spondo/billing @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/exporter-trace-otlp-http @ai-sdk/otel
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(); // reads SPONDO_API_KEY
registerTelemetry(new OpenTelemetry()); // every AI SDK call now records its model and tokens

await trace({ externalCustomerId: account.id, externalProductId: "documind" }, async () => {
  const { text } = await generateText({
    model: openai("gpt-5.5"),
    prompt,
    telemetry: { recordInputs: false, recordOutputs: false }, // keeps the prompt and answer off the span
  });
  await signal("document_uploaded", { pages: doc.pages }); // carries this trace's cost
});

Every span started inside trace() carries the customer and product. In serverless functions, await flush() before returning. The Tracing page has the details.

Instrumenting your LLM client

Spans only have token counts when an instrumentation records them. OpenInference does for the OpenAI and Anthropic clients, and the Vercel AI SDK has its own:

ClientInstallRegister
OpenAI (openai)@arizeai/openinference-instrumentation-openainew OpenAIInstrumentation({ traceConfig }).manuallyInstrument(OpenAI)
Anthropic (@anthropic-ai/sdk)@arizeai/openinference-instrumentation-anthropicnew AnthropicInstrumentation({ traceConfig }).manuallyInstrument(Anthropic)
Vercel AI SDK (ai 7)@ai-sdk/otelregisterTelemetry(new OpenTelemetry())
  • Register the instrumentation after initTracing(), so it uses the tracer provider initTracing() sets up. For OpenInference, pass manuallyInstrument the client library's default export (import OpenAI from "openai"): in an ES module app the instrumentation can't attach any other way.
  • Keep prompts and completions off the spans, so they never leave your servers: traceConfig: { hideInputs: true, hideOutputs: true } for OpenInference, telemetry: { recordInputs: false, recordOutputs: false } on each Vercel AI SDK call. Spondo Billing wouldn't store them anyway.
  • Other instrumentations work too, as long as they record token counts with the gen_ai.* or OpenInference llm.token_count.* attributes. Check that they support your client's version: OpenLLMetry's Anthropic instrumentation (@traceloop/instrumentation-anthropic 0.27) fails with @anthropic-ai/sdk 1.x.

The Vercel AI SDK reports usage on its outer call and again on the provider call inside it; Spondo Billing counts only the provider call, so nothing is counted twice.

From any language

Spondo Billing receives traces over OTLP/HTTP at https://billing.getspondo.com/v1/traces, with the same org API key as the REST API. Configure your OpenTelemetry SDK's exporter with the standard environment variables:

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://billing.getspondo.com/v1/traces
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $SPONDO_API_KEY"

Then set meter.customer_id (and meter.product_id, if you bill more than one product) on the spans of each unit of work. In Python:

from opentelemetry import trace

tracer = trace.get_tracer("documind")

with tracer.start_as_current_span("summarise") as span:
    span.set_attribute("meter.customer_id", account.id)
    span.set_attribute("meter.product_id", "documind")
    summary = client.chat.completions.create(...)  # instrumented: carries token counts

Attributes on the span that holds the token counts are read first, then its resource's. An LLM span is usually a child of yours, so either set the attributes on it (OpenTelemetry baggage plus a span processor that copies baggage to attributes does this for every span), or send a signal with the trace's ID in data.trace_id and let the signal supply the customer.

The endpoint accepts:

  • JSON (Content-Type: application/json) or binary protobuf (application/x-protobuf)
  • gzip, with Content-Encoding: gzip
  • up to 20 MB a request, and 64 MB once unzipped

It answers 200 with { "partialSuccess": {}, "stored": 3 }, where stored counts the new AI call spans. Spans are stored once by span ID, so an exporter that sends a batch again doesn't double your costs. Customer API keys get 403: costs come in on your own key. The API reference documents the endpoint as Send traces.

Every attribute Spondo Billing reads

Several instrumentation conventions are in use, so each value is read from the first of these attributes that is set:

ValueAttributes
Customermeter.customer_id, external_customer_id, customer.id
Productmeter.product_id, external_product_id
Signal namemeter.signal, signal.name
Vendorgen_ai.provider.name, gen_ai.system, llm.provider, llm.vendor, ai.model.provider, meter.vendor
Modelgen_ai.response.model, gen_ai.request.model, llm.model_name, llm.model, ai.model.id, meter.model
Input tokensgen_ai.usage.input_tokens, gen_ai.usage.prompt_tokens, llm.usage.prompt_tokens, llm.token_count.prompt, ai.usage.inputTokens, ai.usage.promptTokens
Output tokensgen_ai.usage.output_tokens, gen_ai.usage.completion_tokens, llm.usage.completion_tokens, llm.token_count.completion, ai.usage.outputTokens, ai.usage.completionTokens
Cached input tokensgen_ai.usage.cached_input_tokens, gen_ai.usage.cache_read_input_tokens, gen_ai.usage.cached_tokens, llm.token_count.prompt_details.cache_read
Stated cost (USD)meter.cost, meter.cost_usd, gen_ai.usage.cost

Costs that aren't traces

For costs you know in aggregate rather than per call, such as a monthly bill from a vendor, send cost records with POST /costs/bulk. They count towards cost and margin like traced costs.