Cost and Token Observability
AI and cloud costs can grow quietly when usage is disconnected from teams, services, and deployment changes
Token spend visible before finance week
Andrey Lesnikov · Senior DevOps Platform Engineer · 2026-10-21 · sources checked 2026-10-21
Part of AI infrastructure engineering
Many LLM dashboards start with GPU utilisation and stop there. That tells you the hardware is busy. It does not tell you which team spent the tokens, whether the fallback model is serving half your traffic, or why time-to-first-token doubled after a deploy. OpenTelemetry can answer those questions if you name things consistently. That is what the GenAI semantic conventions are for.
This guide covers three layers: the conventions (what to call spans, attributes and metrics), the model server (what vLLM exposes today), and cost (turning token counts into money without lying to yourself). I use my own AI Runtime Platform gateway as the worked example, including the places where it does not follow the conventions yet.
The GenAI conventions have moved out of the main semantic-conventions repository into their own open-telemetry/semantic-conventions-genai repository. The old opentelemetry.io pages now say "moved". As of 21 October 2026, the metrics and attributes I use below are marked Development stability. Names can still change. Pin the convention version in your instrumentation, and expect to migrate.
The span conventions define an inference span with:
{gen_ai.operation.name} {gen_ai.request.model}, for example chat qwen2.5-7b-instruct.gen_ai.operation.name (required), such as chat, text_completion, generate_content.gen_ai.provider.name, such as openai.gen_ai.request.model and gen_ai.response.model. These are two attributes on purpose: what you asked for and what actually answered.gen_ai.usage.input_tokens and gen_ai.usage.output_tokens. The registry also defines subsets such as gen_ai.usage.cache_read.input_tokens and gen_ai.usage.reasoning.output_tokens. The spec says input_tokens should include all input types, cached tokens included.Prompt and response content is opt-in. The conventions say content capture must be enabled explicitly, for example via OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT. Leave it off by default on shared clusters. Prompts can carry personal data and secrets, and they make traces expensive to store.
A minimal Python span that follows the conventions:
from opentelemetry import trace
tracer = trace.get_tracer("ai-gateway")
def chat(model: str, messages: list[dict]) -> dict:
with tracer.start_as_current_span(f"chat {model}") as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "openai") # OpenAI-compatible API
span.set_attribute("gen_ai.request.model", model)
resp = backend.chat.completions.create(model=model, messages=messages)
span.set_attribute("gen_ai.response.model", resp.model)
if resp.usage:
span.set_attribute("gen_ai.usage.input_tokens", resp.usage.prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", resp.usage.completion_tokens)
return resp
What my gateway does today, honestly. In app/gateway/routers/chat.py the span is named gen_ai.chat and sets gen_ai.request.model and gen_ai.operation.name. It also sets two attributes that are not in the conventions: gen_ai.usage.cost_usd and gen_ai.server.time_to_last_byte_ms. Routing decisions go under its own ai.runtime.* prefix (ai.runtime.selected_backend, ai.runtime.routing_reason), which is the right pattern. The gen_ai.*-prefixed custom attributes are not, because they squat on a namespace the spec owns. The fix on my list: rename the span to chat {model}, add gen_ai.response.model and the usage attributes, and move cost and latency extras under ai.runtime.*.
The conventions split token metrics into two families. This split is the most useful thing in the spec for anyone doing cost work:
gen_ai.client.inference.usage.input_tokens, …usage.output_tokens, plus subsets …usage.cache_read.input_tokens, …usage.cache_write.input_tokens and …usage.reasoning.output_tokens. The spec calls these "the primary instruments for measuring token consumption" and "a proxy for cost approximation". They are monotonic counters broken down by gen_ai.token.modality.gen_ai.client.inference.operation.input_tokens and …operation.output_tokens. The spec says explicitly that these "should not be used for total usage or cost calculations". Use them for p95/p99 and outliers.Latency has its own instruments: gen_ai.client.operation.duration on the client side, and on model servers gen_ai.server.request.duration, gen_ai.server.time_to_first_token and gen_ai.server.time_per_output_token. There are newer agent and tool metrics too (gen_ai.invoke_agent.duration, gen_ai.invoke_agent.tool_calls, gen_ai.execute_tool.duration). They are handy if your gateway also brokers MCP tool calls.
vLLM exposes metrics on /metrics of its OpenAI-compatible server. These are the ones I wire up first, as named in the vLLM production metrics docs:
| Metric | Type | Why |
|---|---|---|
vllm:num_requests_running, vllm:num_requests_waiting |
Gauge | Queue pressure; a good autoscaling signal |
vllm:kv_cache_usage_perc |
Gauge | KV-cache saturation (1 = 100%) |
vllm:prompt_tokens, vllm:generation_tokens |
Counter | Server-side token throughput |
vllm:time_to_first_token_seconds |
Histogram | TTFT |
vllm:inter_token_latency_seconds |
Histogram | Decode speed |
vllm:e2e_request_latency_seconds, vllm:request_queue_time_seconds |
Histogram | Where the time goes |
vllm:prefix_cache_hits, vllm:prefix_cache_queries |
Counter | Prefix-cache effectiveness |
In the Prometheus text format, counters get a _total suffix. Check your own /metrics output before writing queries. A pull request adding native OTLP metrics export to vLLM (#35469) was closed without being merged. So for metrics, scrape Prometheus and convert with the OpenTelemetry Collector if your backend wants OTLP.
# OpenTelemetry Collector: scrape vLLM, receive app traces/metrics over OTLP
receivers:
otlp:
protocols:
grpc: {}
http: {}
prometheus:
config:
scrape_configs:
- job_name: vllm
scrape_interval: 15s
kubernetes_sd_configs:
- role: pod
namespaces: { names: ["ai-runtime"] }
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
regex: vllm
action: keep
processors:
memory_limiter: { check_interval: 1s, limit_mib: 256 }
batch: {}
exporters:
otlphttp:
endpoint: http://tempo.observability.svc.cluster.local:4318
prometheusremotewrite:
endpoint: http://prometheus.monitoring.svc:9090/api/v1/write
service:
pipelines:
traces: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [otlphttp] }
metrics: { receivers: [otlp, prometheus], processors: [memory_limiter, batch], exporters: [prometheusremotewrite] }
This is a sketch: the label selector, endpoints and exporters depend on your stack. The prometheusremotewrite exporter ships in the Collector contrib distribution, and Prometheus must have its remote-write receiver enabled. The collector in my runtime repo (deploy/observability/otel-collector.yaml) currently has only a traces pipeline (OTLP in, Tempo and debug out). vLLM metrics there are picked up by a ServiceMonitor in the Helm chart, not by the collector.
The same vllm:num_requests_waiting gauge drives scaling. My KEDA ScaledObject (deploy/keda/vllm-scaledobject.yaml) uses the query sum(vllm:num_requests_waiting{namespace="ai-runtime"}) with threshold 4. That threshold is a starting value in a reference manifest, not a tuned result.
Cost is where observability most often becomes fiction. Rules I follow:
gateway_chat_estimated_cost_usd_total and an x-ai-estimated-cost-usd response header. The name says "estimated" because it comes from a price table, not from an invoice.429, fails closed with 503 when its store or policy is broken, and settles to actual priced usage afterwards. It is off by default. Its evidence is on its test suite with one Redis: the repo says estimates can differ from actual usage and settlement can lag.An example recording rule for cost per model from server-side counters, assuming a price-table metric you maintain yourself (llm_price_usd_per_token, labelled by model_name and direction):
groups:
- name: llm-cost
rules:
- record: llm:cost_usd:rate5m
expr: |
sum by (model_name) (
rate(vllm:prompt_tokens_total[5m])
* on (model_name) group_left
llm_price_usd_per_token{direction="input"}
)
+
sum by (model_name) (
rate(vllm:generation_tokens_total[5m])
* on (model_name) group_left
llm_price_usd_per_token{direction="output"}
)
For self-hosted models the "price" is an internal rate (GPU-hour cost divided by expected tokens), and that is an assumption you should write down next to the rule. The cost and token observability case describes the full loop (meter, attribute, budget, alert or cap).
Separate dashboards for traces, metrics and logs produce three stories per incident. The minimum glue I use:
The wider pattern (collector topology, correlation, runbooks) is in the OpenTelemetry observability mesh case. The boundary that makes attribution possible is described in the cloud-native AI gateway case.
Cases
AI and cloud costs can grow quietly when usage is disconnected from teams, services, and deployment changes
Token spend visible before finance week
Metrics, logs, and traces often exist separately, making incidents slower and ownership unclear
Request path explainable end-to-end
AI workloads often start as API calls but become production systems that need identity, policy, scaling, telemetry, and clear ownership
Every model call: policy + trace + budget
AI usage needs routing, policy, budget awareness, and provider resilience
One boundary: rate, budget, fallback, OTel