How do I record token counts with the OpenTelemetry JavaScript SDK?
With the OpenTelemetry JavaScript SDK, you record token counts in Rius by setting gen_ai.usage.input_tokens and gen_ai.usage.output_tokens on the span for each model call. The SDK does not set these for you, so you add them with span.setAttributes after the model responds and before the span ends. Set gen_ai.request.model on the same span, and Rius computes the cost from the model and the counts. The full recipe is in Trace LLM calls with OpenTelemetry.
How it works
Rius reads token counts from span attributes, not from the exporter or from resource settings. The attributes that matter for tokens are:
gen_ai.request.model: the model you asked for. Cost needs it.gen_ai.usage.input_tokens: prompt tokens, including cached ones.gen_ai.usage.output_tokens: completion tokens, including reasoning ones.gen_ai.usage.cache_read.input_tokensandgen_ai.usage.cache_write.input_tokens: prompt-cache reads and writes, which are subsets of the input count.gen_ai.usage.reasoning.output_tokens: reasoning tokens, a subset of the output count.
The OpenTelemetry JavaScript SDK sends whatever attributes you put on a span and nothing more. It does not read token usage from your model client, so a span you create by hand carries no counts until you add them. The token values come from the model response, so you set them after the call returns.
Rius computes cost on its side from the request model plus the token counts, with input, output, cache read and cache write each priced at its own rate. A span needs the model and at least one count, and any count you leave out is read as 0. A span with no model, a model Rius does not know, or no counts gets no cost. Never send a cost attribute. The span attribute reference lists every key Rius reads.
Set it up
- Point the exporter at Rius. This is the shared setup, covered in full on Vanilla OpenTelemetry SDKs. The standard exporter variables are:
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.eu.console.rius-glassflow.com"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer <your API key>"
export OTEL_SERVICE_NAME="my-agent"
# Keep long LLM spans whole; the default of 128 attributes is too low
export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=4096Then start the Node SDK with the protobuf exporter. Without a registered provider, trace.getTracer returns a tracer that records nothing. @opentelemetry/exporter-trace-otlp-http sends JSON, which also works.
npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto @opentelemetry/apiimport { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
const sdk = new NodeSDK({
// No url or headers in code, so the exporter reads OTEL_EXPORTER_OTLP_*
traceExporter: new OTLPTraceExporter(),
// Rius ingests traces only
metricReaders: [],
logRecordProcessors: [],
})
sdk.start()
// ... your agent code ...
// Flushes queued spans before the process exits (top-level await needs an ES module)
await sdk.shutdown()-
Start a span for the model call and set the request attributes before you call the model. Name the span the way the GenAI conventions ask, the operation followed by the model.
-
Call the model, then set the token counts from its response and end the span:
import { trace } from '@opentelemetry/api'
const tracer = trace.getTracer('my-agent')
const span = tracer.startSpan('chat gpt-4o')
span.setAttributes({
'openinference.span.kind': 'LLM',
'gen_ai.operation.name': 'chat',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.provider.name': 'openai',
})
const response = await callTheModel()
span.setAttributes({
'gen_ai.usage.input_tokens': response.usage.prompt_tokens,
'gen_ai.usage.output_tokens': response.usage.completion_tokens,
'gen_ai.response.model': response.model,
'gen_ai.output.messages': JSON.stringify([
{ role: 'assistant', parts: [{ type: 'text', content: response.text }] },
]),
})
span.end()-
If your provider reports them, add
gen_ai.usage.cache_read.input_tokens,gen_ai.usage.cache_write.input_tokensandgen_ai.usage.reasoning.output_tokensthe same way. Keep the input and output counts as totals, since the cache and reasoning counts are subsets of them. -
Run one call, open Traces in the Rius console, and open the new trace. The span should show the model name, the token counts and the cost.
If the span arrives without a model or tokens, check that nothing between your code and Rius strips gen_ai.* keys and that the attribute limit is raised, as in step 1.
What you get
- Input and output token counts on each model span, with cache and reasoning counts when you send them.
- Cost per call, computed by Rius as a list-price estimate in US dollars from the model and the counts.
- The span counted as a model call, because it carries the LLM kind and the chat operation, so it appears in model analytics.
- Nested spans that arrive as one trace when you create them under a parent span the normal OpenTelemetry way.
The Vanilla OpenTelemetry SDKs page shows the same pattern in context, and the attribute reference has the message shape for prompts and replies.
When something else fits
- If your instrumentation already emits OpenInference or OpenLLMetry attributes, you do not need to set the
gen_ai.usagekeys by hand: Rius maps their token counts onto them at ingest. - If you want token counts without writing attributes yourself, the Rius TypeScript SDK with an OpenAI or Anthropic integration emits the model and token usage for you.