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 typesapplication/json (the protobuf JSON mapping) and application/x-protobuf
Content encodingsidentity or gzip
Success status200, not 202
Success bodyAn OTLP ExportTraceServiceResponse, in the same media type as the request
Row limit5,000 GenAI spans, and exceeding it returns 413, not 400
Body limit8 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:

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.

CodeMeaning
400The body is not valid OTLP JSON or protobuf, or is malformed gzip
401Unknown key, or an inactive workspace
402quota_exceeded: the monthly allowance is spent
403The key lacks the ingest scope
413Body too large, or more than 5,000 GenAI spans
415Unsupported 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 fieldOTLP source
Trace hierarchytraceId, spanId, parentSpanId
Providergen_ai.provider.name, falling back to the legacy gen_ai.system
Modelgen_ai.response.model, then gen_ai.request.model
Input and output tokensgen_ai.usage.input_tokens, gen_ai.usage.output_tokens
Cache tokensgen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_creation.input_tokens
Sessionmetergraph.session.id, then session.id
Conversationgen_ai.conversation.id
Input contentgen_ai.input.messages, gen_ai.system_instructions, or the legacy indexed prompt attributes
Output contentgen_ai.output.messages, or the legacy completion attributes
Response detailsgen_ai.response.id, gen_ai.response.finish_reasons
Errorerror.type, the OTLP error status, or metergraph.status
LatencyDerived 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.

This is the part worth getting right, because route is the unit analysis works in. The first of these that is 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. 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.

  4. 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 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.

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.

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(),
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.

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.

KeyMeaning
scopeFiltered out by include_scopes or exclude_scopes
not-genaiNot a GenAI span at all. Every ordinary span on a shared tracer provider lands here
ineligible-kindA GenAI observation that is not an LLM call, such as a span, event or tool record
no-modelEligible, but carrying no model attribute. This one also emits a rate-limited warning
parse-degradedNot 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.