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.
Point an exporter at the endpoint
Section titled “Point an exporter at the endpoint”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.
The wire contract
Section titled “The wire contract”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:
{}On partial success it carries the OTLP partial-success block:
{"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 |
What Metergraph reads
Section titled “What Metergraph reads”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.
How a route name is resolved
Section titled “How a route name is resolved”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:
-
Section titled “metergraph.route”metergraph.routeSet this and nothing else is consulted. If you care what your routes are called, set it.
-
Section titled “gen_ai.prompt.name”gen_ai.prompt.nameUseful when your prompts are already named and managed.
-
A LiteLLM generation name
Section titled “A LiteLLM generation name”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. -
Section titled “service.name and the operation”service.nameand the operationJoined as
service/operation, where the operation isgen_ai.operation.nameorinferencewhen that is absent. This only applies whenservice.nameis set. -
The span name
Section titled “The span name”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
Section titled “Content”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
Section titled “Pricing”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.
The Python span exporter
Section titled “The Python span exporter”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]'from metergraph.opentelemetry import MetergraphGenAIExporterIt 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
Section titled “LangSmith”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:
import osfrom opentelemetry import tracefrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import BatchSpanProcessorfrom 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 and Azure
Section titled “Bedrock and Azure”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.
Phoenix
Section titled “Phoenix”from phoenix.otel import registerfrom opentelemetry.sdk.trace.export import BatchSpanProcessorfrom metergraph.opentelemetry import MetergraphGenAIExporter
tracer_provider = register(project_name="my-app") # your existing setuptracer_provider.add_span_processor( BatchSpanProcessor(MetergraphGenAIExporter()), replace_default_processor=False,)Langfuse
Section titled “Langfuse”from opentelemetry import tracefrom opentelemetry.sdk.trace.export import BatchSpanProcessorfrom metergraph.opentelemetry import MetergraphGenAIExporter
trace.get_tracer_provider().add_span_processor( BatchSpanProcessor(MetergraphGenAIExporter()),)LiteLLM
Section titled “LiteLLM”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.
# pip install 'metergraph[otel]' 'litellm[proxy]>=1.96.2,<2'
import litellmfrom litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfigfrom 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_contentoption and reject it, so leave it out there. On 1.85 and later it is accepted, andcapture_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. Ametergraph.routeorgen_ai.prompt.namespan 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
metadatadict 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.
Filtering by instrumentation scope
Section titled “Filtering by instrumentation scope”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"])Reading the skip counters
Section titled “Reading the skip counters”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.
- Ingest API for the native
/v1/ingestcontract - Write your own client if neither path fits
- Import existing traffic to backfill history rather than capture live traffic
- Errors for every status code the endpoints return