Skip to content

OTLP spans

If your application already emits OpenTelemetry GenAI spans, you do not need to wrap anything. Point a trace exporter at POST /v1/traces and Metergraph will normalize, price and store the GenAI spans it finds.

This is the right path for a provider Metergraph has no wrapper for, for a framework that already instruments itself, and for a language with no Metergraph SDK. The trade is attribution: on this path there is no stack walk and no automatic function name. You name your own routes.

Environment
export METERGRAPH_APP_TOKEN=mg_...
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='https://d2xus7mp8zdv6t.cloudfront.net/v1/traces'
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL='http/protobuf'
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20${METERGRAPH_APP_TOKEN}"

That endpoint is the hosted base. Replace it with your own deployment’s base URL if you run Metergraph yourself. Support for these standard variables varies by language, so check your exporter’s documentation.

POST /v1/traces takes an OTLP ExportTraceServiceRequest. It differs from POST /v1/ingest in several ways that matter if you are writing the client yourself.

Value
Content types application/json (the protobuf JSON mapping) and application/x-protobuf
Content encodings identity or gzip
Success status 200, not 202
Success body An OTLP ExportTraceServiceResponse, in the same media type as the request
Row limit 5,000 GenAI spans, and exceeding it returns 413, not 400
Body limit 8 MiB, checked before and after decompression
Protobuf structure limit 10,000 spans of any kind, and 250,000 nested messages, per application/x-protobuf request. Exceeding either returns 413

The response is not the {"accepted": N, "ignored": N, "batch": "..."} shape that /v1/ingest returns. On full success the JSON body is an empty object:

200 OK
{}

On partial success it carries the OTLP partial-success block:

200 OK, partial
{"partialSuccess":{"rejectedSpans":"12","errorMessage":"Metergraph accepts GenAI spans only"}}

Note that rejectedSpans is a string in the JSON mapping, which is what the protobuf JSON encoding does with 64-bit integers.

Code Meaning
400 The body is not valid OTLP JSON or protobuf, or is malformed gzip
401 Unknown key, or an inactive workspace
402 quota_exceeded: the monthly allowance is spent
403 The key lacks the ingest scope
413 Body too large, more than 5,000 GenAI spans, or a protobuf request over its span or nested-message limit
415 Unsupported content type or Content-Encoding

A span qualifies if any of its attributes starts with gen_ai.. Attributes are merged before they are read: resource attributes first, then scope attributes, then the span’s own, so the span wins a conflict.

Metergraph field OTLP source
Trace hierarchy traceId, spanId, parentSpanId
Provider gen_ai.provider.name, falling back to the legacy gen_ai.system
Request ID gen_ai.response.id
Model gen_ai.response.model, then gen_ai.request.model
Input and output tokens gen_ai.usage.input_tokens, gen_ai.usage.output_tokens
Cache tokens gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_creation.input_tokens
Session metergraph.session.id, then session.id
Conversation gen_ai.conversation.id
Input content gen_ai.input.messages, gen_ai.system_instructions, or the legacy indexed prompt attributes
Output content gen_ai.output.messages, or the legacy completion attributes
Tool definitions gen_ai.tool.definitions, or the older llm.request.functions.N.* that LiteLLM emits when that is absent
Response details gen_ai.response.id, gen_ai.response.finish_reasons
Error error.type, the OTLP error status, or metergraph.status
Latency Derived from startTimeUnixNano and endTimeUnixNano

amazon-bedrock, aws and aws-bedrock are all normalized to bedrock, so Bedrock spans line up regardless of which convention your instrumentation uses.

Following the GenAI semantic conventions, gen_ai.usage.input_tokens is read as the total input, cache reads and cache writes included. Metergraph bills the uncached remainder at the input rate and the cache counts at their own rates, so a cached token is never charged twice. The stored input count is the total you sent. If your instrumentation reports cache counts larger than the input total, the input total is treated as uncached input.

Trace and span IDs are accepted as hexadecimal or as valid base64, and stored as lowercase hexadecimal.

Route names give you control over what Metergraph considers to be your workloads. Workloads are the units Metergraph uses for opportunity analysis, monitoring, and alerting. The first route value present wins:

  1. Set this and nothing else is consulted. If you care what your routes are called, set it.

  2. Useful when your prompts are already named and managed.

  3. LiteLLM names its span after metadata={"generation_name": ...}. A name you set is used; LiteLLM’s own defaults (litellm_request, raw_gen_ai_request, or the operation and model) are not.

  4. Joined as service/operation, where the operation is gen_ai.operation.name or inference when that is absent. This only applies when service.name is set.

  5. The last fallback before the operation name itself.

A trace name resolves through a similar chain: metergraph.trace.name, then gen_ai.prompt.name, then a LiteLLM generation name, then service.name, then the span name.

A template hash comes from metergraph.template_hash if you set one. Otherwise, when gen_ai.prompt.name is present, it is derived from that name plus gen_ai.prompt.version, so two versions of a named prompt do not collide.

metergraph.unit.name, metergraph.unit.count and metergraph.cost_usd are read as well, if you emit them.

Content is captured only when your exporter includes the GenAI input, system and output attributes. To stay metadata-only, do not record or export those attributes. Metergraph never infers absent content.

Before anything is written durably, the API removes request, response and tool-argument content from rows marked as opted out, and recursively strips credential, authorization, cookie, secret, token, API key and transport-header fields. OTLP transport headers and the opaque payload bytes are never stored: the endpoint writes sanitized, normalized rows and then returns 200. See Content and privacy.

Pricing happens on the server, after ingest, from the effective-dated catalog. Your original provider and model identifiers stay on the row as provenance, and any cost your client reported stays alongside the catalog price rather than replacing it. A model the catalog does not know remains visible as unpriced rather than being rejected. See Model catalog and pricing.

Python applications that already trace LLM calls have a shorter path. The SDK ships an OpenTelemetry SpanExporter that reads spans in several dialects and sends them through Metergraph’s own transport.

pip install 'metergraph[otel]'
app/telemetry.py
from metergraph.opentelemetry import MetergraphGenAIExporter

It reads OpenInference (Arize Phoenix), Langfuse and LangSmith spans as well as the standard gen_ai.* convention, so an application instrumented for any of them captures by registering one exporter.

LangSmith’s default tracing mode sends runs to its own API without emitting OpenTelemetry spans. Set its tracing mode to otel or hybrid, and install the Metergraph exporter before the first LangSmith client is built:

app/telemetry.py
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from metergraph.opentelemetry import MetergraphGenAIExporter
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_TRACING_MODE"] = "otel" # or "hybrid"
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(MetergraphGenAIExporter()))
trace.set_tracer_provider(provider)

LangSmith emits chains, tools and retrievers as GenAI spans too. Metergraph counts only spans whose langsmith.span.kind is llm. LangSmith spans carry no provider cost or time-to-first-token values, so Metergraph prices them from its catalog and leaves TTFT absent.

Bedrock works through the standard GenAI span path. Install the Bedrock instrumentation package alongside the Metergraph extra, then register the same exporter on its tracer provider:

pip install "metergraph[otel]" "opentelemetry-instrumentation-bedrock>=0.49b0,<1"

Bedrock provider spellings such as amazon-bedrock, aws and aws-bedrock are normalized to bedrock. Azure OpenAI spans use the standard GenAI attributes and are normalized to azure; no Metergraph-specific wrapper is required. Use one capture path per call, either the provider wrapper or the exporter, never both.

app/telemetry.py
from phoenix.otel import register
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from metergraph.opentelemetry import MetergraphGenAIExporter
tracer_provider = register(project_name="my-app") # your existing setup
tracer_provider.add_span_processor(
BatchSpanProcessor(MetergraphGenAIExporter()),
replace_default_processor=False,
)
app/telemetry.py
from opentelemetry import trace
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from metergraph.opentelemetry import MetergraphGenAIExporter
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(MetergraphGenAIExporter()),
)

LiteLLM is the qualified integration for applications that want capture without touching call sites. Attach the exporter to LiteLLM’s OpenTelemetry callback and leave your litellm.completion() calls alone, apart from naming them.

app/telemetry.py
# pip install 'metergraph[otel]' 'litellm[proxy]>=1.96.2,<2'
import litellm
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
from metergraph.opentelemetry import MetergraphGenAIExporter
litellm.callbacks.append(OpenTelemetry(OpenTelemetryConfig(
exporter=MetergraphGenAIExporter(),
)))
response = litellm.completion(
model="openai/gpt-5-mini",
messages=messages,
metadata={"generation_name": "ticket-triage"},
)

The install line matches the SDK’s own. The same callback also works on older releases: a local LiteLLM 1.84.10 run captured every call.

  • LiteLLM logs message content on its spans by default, and Metergraph needs that content to analyse the calls. Releases before 1.85 have no capture_message_content option and reject it, so leave it out there. On 1.85 and later it is accepted, and capture_message_content="SPAN_ONLY" keeps content on the span.
  • Do not set litellm.turn_off_message_logging. It removes messages, tool definitions and outputs from the spans, so Metergraph can count the calls but cannot analyse them.
  • Name each call with metadata={"generation_name": "<workload name>"}. LiteLLM uses it as the span name and Metergraph records it as the call’s route. Without it every call lands on one generic route. A metergraph.route or gen_ai.prompt.name span attribute still takes precedence.
  • To group the calls of a multi-step agent run into one trace, make them inside one application span and set USE_OTEL_LITELLM_REQUEST_SPAN=true. Without that variable LiteLLM writes each call onto the application span itself, and only one of the calls is recorded.
  • Pass a new metadata dict to each call. Reusing one dict across calls makes LiteLLM 1.103 drop spans.

LiteLLM names the upstream provider in gen_ai.system, or in gen_ai.provider.name when OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental is set. Older LiteLLM versions may instead include the provider in gen_ai.request.metadata.litellm_params.custom_llm_provider. Metergraph reads that legacy value when the standard provider attribute is absent, including when the metadata arrives as a JSON string.

When LiteLLM runs as a proxy, service.name=litellm remains route context. The upstream value in custom_llm_provider is the provider used for pricing. The normalizer does not mark every proxy call as a gateway call, because the span does not prove that a gateway-priced channel served it. If neither the standard provider attribute nor the legacy provider metadata is present, Metergraph reads the prefix of litellm.provider.model (for example bedrock in bedrock/anthropic.claude-sonnet-5-v1:0) when it names a known provider. Otherwise the call stays without a provider instead of receiving an inferred pricing channel.

Both arguments take instrumentation scope names. exclude_scopes always wins, and when include_scopes is set only the listed scopes pass.

MetergraphGenAIExporter(include_scopes=["openinference.instrumentation.openai"])

The exporter keeps a public skipped dictionary, which is the quickest way to find out why a span you expected did not turn into a row.

Key Meaning
scope Filtered out by include_scopes or exclude_scopes
not-genai Not a GenAI span at all. Every ordinary span on a shared tracer provider lands here
ineligible-kind A GenAI observation that is not an LLM call, such as a span, event or tool record
no-model Eligible, but carrying no model attribute. This one also emits a rate-limited warning
parse-degraded Not a skip. The span captured, but one or more attributes held malformed JSON, so some fields may be incomplete

force_flush(timeout_millis) delivers queued rows and shutdown() stops Metergraph’s background work, so the exporter fits your existing OpenTelemetry shutdown path without a separate call.

Runnable examples: LiteLLM, Langfuse, Phoenix.