In short: point an OTLP/HTTP trace exporter at https://fiscallm.com/api/v1/otel with your ingest key in the Authorization header, and put a user.id attribute on your spans. Every AI call span becomes a cost event for that user. There is no FiscaLLM SDK to install.
Traces already say which model ran and how many tokens it used. What they don't say is whether the user behind them pays you more than they cost. FiscaLLM adds that: it prices each call, adds it up per user, and sets it next to revenue from RevenueCat, Stripe or your own billing.
Create an app and copy the ingest key. If your OpenTelemetry SDK is configured by environment variables and FiscaLLM is the only place traces go:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://fiscallm.com/api/v1/otel/v1/traces
OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=Bearer%20<FISCALLM_KEY>Most teams already export somewhere else. Add a second exporter in code (Node shown):
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
const fiscallm = new OTLPTraceExporter({
url: "https://fiscallm.com/api/v1/otel/v1/traces",
headers: { Authorization: `Bearer ${process.env.FISCALLM_KEY}` },
});
new NodeTracerProvider({
spanProcessors: [
new BatchSpanProcessor(yourExistingExporter),
new BatchSpanProcessor(fiscallm),
],
}).register();Or in the OpenTelemetry Collector, with no code change at all:
exporters:
otlphttp/fiscallm:
endpoint: https://fiscallm.com/api/v1/otel
headers:
Authorization: Bearer ${env:FISCALLM_KEY}
service:
pipelines:
traces:
exporters: [otlphttp/fiscallm, your-existing-exporter]JSON and protobuf are both accepted, with or without gzip. Metrics and logs sent to the same endpoint are accepted and dropped.
Set user.id to the same ID your billing knows (the RevenueCat app user ID, or the user_id in Stripe metadata). The safest place is on the AI call span itself, or as a resource attribute if one process serves one user.
import { trace } from "@opentelemetry/api";
trace.getActiveSpan()?.setAttribute("user.id", userId);FiscaLLM also looks at the span's parents and the rest of the trace, so an ID set on your request span usually works. One caveat: a batch exporter can send a child span before its parent has ended. If the ID is only on the parent, that call can arrive without a user and be skipped. Setting the ID on every span avoids it.
These attribute names are read, in this order:
fiscallm.user_id, user.id, enduser.id, app.user.id,
ai.telemetry.metadata.userId, ai.settings.context.userId,
langfuse.user.id, traceloop.association.properties.user_id,
langsmith.metadata.user_id, session.user.id| Value | Attributes |
|---|---|
| Which spans count | gen_ai.operation.name of chat, text_completion, generate_content or embeddings; OpenInference LLM and EMBEDDING spans |
| Model | gen_ai.response.model, gen_ai.request.model, llm.model_name |
| Provider | gen_ai.provider.name, gen_ai.system |
| Tokens | gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_creation.input_tokens (and the OpenInference llm.token_count.* names) |
| Feature (optional) | fiscallm.feature, gen_ai.agent.name, traceloop.workflow.name |
| Exact cost (optional) | fiscallm.cost_usd, when you already know the cost and want it used as is |
| Job (optional) | fiscallm.job_id, for pay-per-job apps |
Cached input is priced at the cached rate and not counted twice. A model that is not in the price table is stored and shown as unpriced until you set a price.
The app's Setup page shows the first event when it arrives. From the exporter's side, a 200 with an empty body means every AI call span was stored. A 200 with partialSuccess names what was skipped and why, for example spans with no user ID. A 401 means the key is missing or wrong.
Then connect revenue (RevenueCat, Stripe or a CSV) and the dashboard shows margin per user. The same numbers are available to scripts and AI agents through the MCP server and the read API.
generateText and streamTextNo. Add FiscaLLM as a second exporter. Your current backend keeps getting every span; FiscaLLM reads the AI call spans and ignores the rest.
No. It reads the model, the token counts, the user ID and an optional feature name. Message content on a span is never read or stored.
Anything that follows the OpenTelemetry GenAI semantic conventions: the Vercel AI SDK, OpenLLMetry (Traceloop), OpenInference (Arize) and the OpenTelemetry instrumentations for the OpenAI and Anthropic SDKs.
It is skipped and counted in the response as a rejected span, with a message that says to set user.id. Cost without a user cannot be matched to revenue.
No. Only the spans that are one model call are counted. Wrapper spans (the agent, a step, a tool call) are skipped, and each span is stored once even if the exporter retries.