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 |
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, or more than 5,000 GenAI spans |
| 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 |
| 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 |
| 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.
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”This is the part worth getting right, because route is the unit analysis works in. The first of these that is 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.
-
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 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.
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.
# 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(), capture_message_content="SPAN_ONLY",)))Message content is sensitive, so it is off unless you turn it on, which is what
capture_message_content does above.
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