OpenTelemetry
If your agent already emits OpenTelemetry, mcpd is an endpoint change. This page is the whole OTLP path: the three endpoints, the encoding, and every attribute mcpd reads.
The three endpoints
mcpd serves the OTLP HTTP endpoints on the same port as its query API and its interface. There is no collector to run in front of it, no second container, and no separate receiver port.
| Endpoint | Accepts | Answers with |
|---|---|---|
POST /v1/traces | ExportTraceServiceRequest | partialSuccess.rejectedSpans |
POST /v1/logs | ExportLogsServiceRequest | partialSuccess.rejectedLogRecords |
POST /v1/metrics | ExportMetricsServiceRequest | partialSuccess.rejectedDataPoints |
Every successful export answers with OTLP's own partialSuccess
shape carrying a count. That is how a collector tells a sender it dropped
something, and exporters read it. Returning an empty body would let a sender
report a success it did not get.
Point an exporter at it
Two variables do the work. Set them in the environment of the process that runs your agent, and nothing else changes.
exporter
# The base URL. Your exporter appends /v1/traces itself.
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
# Optional. Send gzip if your payloads are large.
export OTEL_EXPORTER_OTLP_COMPRESSION=gzip
# Only when mcpd runs with --ingest-key.
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
Two details are worth knowing before you debug a silence. First,
OTEL_EXPORTER_OTLP_ENDPOINT is a base URL and the exporter
appends the signal path to it. Set
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT instead if you want to give
the full URL, path included. Second, port 4317 is conventionally the gRPC
port in OpenTelemetry and 4318 the HTTP one. mcpd serves HTTP on 4317
because it serves everything on one port, so an exporter left on its default
port sends to 4318 and you see nothing arrive. Name the port explicitly.
Both encodings, for traces
/v1/traces reads the content-type header and
accepts either encoding: OTLP/protobuf when it contains
protobuf, OTLP/JSON otherwise. Protobuf is the OpenTelemetry
default, so an unmodified SDK pointed at mcpd sends traces that land without
any configuration at all.
/v1/logs and /v1/metrics accept JSON only. A
protobuf body there is refused as body is not valid JSON. If
you are sending all three signals, set the protocol once, globally:
json for every signal
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
When no content-type is sent at all, mcpd assumes
application/json.
Compression and size
A request body may arrive with content-encoding: gzip, with
identity, or with no encoding header. Anything else is refused
by name: content-encoding "br" is not supported; use gzip or send the
body uncompressed. Responses are compressed by content negotiation,
so a large trace read back over a slow link is compressed too.
Two limits apply to one request. The body limit is 32 MiB and is measured
on what arrives, so gzip buys you room. The batch limit is 10,000 signals,
counted after decoding on every ingest door: spans on
/v1/traces, records on /v1/logs, data points on
/v1/metrics and everything in the request on
/v1/ingest. A larger batch is refused with
413 payload_too_large naming both numbers. Both limits are
settings: --max-body-bytes and
--max-batch-signals, described on
Configuration.
What happens between arriving and being stored
An OTLP span and a native span take the same path. That is the point of the pipeline being shared: the order below cannot drift between the two doors.
-
Decode
OTLP or native, gzip or plain
-
Sample
Decided once, at the root
-
Redact
Before anything is written
-
Price
From the configured price book
-
Store
One transaction per batch
Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.
Costing at ingest is why an OTLP span gets a cost figure at all. mcpd prices it from the price book this server is running with, at the moment it arrives, so changing your rates later cannot silently rewrite what last month cost. See Cost accounting.
Both semantic conventions
Two competing sets of attribute names are in use for AI spans:
OpenTelemetry's own gen_ai.* and OpenInference's
llm.* with openinference.span.kind. mcpd reads
both, and its own mcpd.* names on top. You do not rename
anything.
Each row below is read in order, first match wins. An attribute that no row names is still kept: the whole flattened attribute map is stored on the span, so nothing you send is lost by not being mapped.
Model, payload and usage
| mcpd field | Read from, in order |
|---|---|
model | gen_ai.request.model, gen_ai.response.model, llm.model_name, mcpd.model |
provider | gen_ai.system, llm.provider, mcpd.provider |
input | input.value, gen_ai.prompt, mcpd.input |
output | output.value, gen_ai.completion, mcpd.output |
entity_name | gen_ai.agent.name, mcpd.entity.name |
usage.input_tokens | gen_ai.usage.input_tokens, gen_ai.usage.prompt_tokens, llm.token_count.prompt, mcpd.usage.input_tokens |
usage.output_tokens | gen_ai.usage.output_tokens, gen_ai.usage.completion_tokens, llm.token_count.completion, mcpd.usage.output_tokens |
usage.total_tokens | llm.token_count.total, gen_ai.usage.total_tokens |
input_details.cache_read | gen_ai.usage.cache_read_input_tokens, llm.token_count.prompt_details.cache_read, mcpd.usage.cache_read_tokens |
input_details.cache_write | gen_ai.usage.cache_creation_input_tokens, llm.token_count.prompt_details.cache_write, mcpd.usage.cache_write_tokens |
input_details.audio | llm.token_count.prompt_details.audio |
output_details.reasoning | llm.token_count.completion_details.reasoning, gen_ai.usage.reasoning_tokens |
output_details.audio | llm.token_count.completion_details.audio |
A span with none of the three token counts carries no usage object at all, rather than one full of zeroes. Zero input tokens and unknown input tokens are different facts, and a cost report built on the first when it meant the second is wrong in a way nobody notices.
Correlation
Correlation fields are what let you ask for one user's runs, or one session's. Span attributes are read first, then resource attributes.
| mcpd field | Read from, in order |
|---|---|
project | mcpd.project on the span, then on the resource, then the daemon's --project |
environment | mcpd.environment, resource deployment.environment.name, resource deployment.environment |
service_name | resource service.name |
service_version | resource service.version |
session_id | gen_ai.conversation.id, session.id, mcpd.session_id |
thread_id | mcpd.thread_id, thread.id |
user_id | user.id, enduser.id, mcpd.user_id |
request_id | mcpd.request_id, http.request.id |
release | mcpd.release, resource service.version |
run_id, resource_id, experiment_id | mcpd.run_id, mcpd.resource_id, mcpd.experiment_id |
tags | mcpd.tags, as an array of strings |
Status and errors
OTLP status code 1 becomes ok and 2
becomes error. An unset status with an end time is recorded as
ok, because a span that finished without complaint did.
An unset status with no end time stays unset: the span is still
running, and mcpd stores it that way rather than inventing an outcome.
On an error span, the message comes from status.message, then
exception.message, and failing both the literal string
unspecified error. The error kind comes from
exception.type and the stack from
exception.stacktrace. An error span always carries a message,
because "something failed" with no text is not something you can act on.
Span kind
The kind drives how a span is drawn in the waterfall. mcpd infers it in
order of specificity, and falls back to generic, which is
always safe. Mislabelling a span is worse than declining to label it.
mcpd.span.kind, when it names a kind mcpd knows.openinference.span.kind.gen_ai.operation.name.- The presence of an attribute that only one kind of span carries.
| Attribute value | mcpd kind |
|---|---|
openinference.span.kind=LLM | model_generation |
openinference.span.kind=CHAIN | workflow_run |
openinference.span.kind=TOOL | tool_call |
openinference.span.kind=AGENT | agent_run |
openinference.span.kind=RETRIEVER | retrieval |
openinference.span.kind=EMBEDDING | rag_embedding |
openinference.span.kind=RERANKER | rag_action |
openinference.span.kind=GUARDRAIL | guardrail |
openinference.span.kind=EVALUATOR | scorer_run |
gen_ai.operation.name=chat | model_generation |
gen_ai.operation.name=text_completion | model_generation |
gen_ai.operation.name=generate_content | model_generation |
gen_ai.operation.name=embeddings | rag_embedding |
gen_ai.operation.name=execute_tool | tool_call |
gen_ai.operation.name=invoke_agent | agent_run |
gen_ai.operation.name=create_agent | agent_run |
any of gen_ai.system, llm.model_name, llm.provider present | model_generation |
mcp.tool.name present | mcp_tool_call |
http.request.method or http.method present | http_request |
| anything else | generic |
A span kind mcpd does not recognise under
openinference.span.kind or
gen_ai.operation.name falls to generic rather
than being rejected. The full list of kinds is in the
data model.
Logs and metrics
Log records map by severity number, the way the specification prescribes:
1 to 4 is trace, 5 to 8 debug, 9 to 12 info, 13 to 16 warn, 17 to 20 error,
and anything above that is fatal. With no severity number, a record is
treated as info. The message comes from body.stringValue, the
logger name from the scope name, and traceId and
spanId are kept when they are valid, which is what puts a log
line next to the span that emitted it.
For metrics, sums become counters and gauges become gauges. Histograms are recorded by their sum and count with the bucket boundaries preserved: mcpd does not pretend it can re-derive an exact quantile from a pre-aggregated histogram. Exemplars are kept, which is what lets a spike on a chart open the run that caused it. Most tools discard them.
Ids, and W3C traceparent
mcpd uses OpenTelemetry's identifier shapes exactly: a trace id is 128 bits as 32 lowercase hex characters, a span id is 64 bits as 16. Keeping the wire format identical is what removes the translation layer. The all-zero id is reserved to mean "no trace" and is refused wherever it appears, including in ids mcpd generates itself.
OTLP/JSON specifies hex for id fields, but several SDKs emit base64. mcpd accepts both and stores hex. A span whose ids are missing, the wrong length or all-zero is skipped and the rest of the batch is still written, because a collector that rejects ten thousand good spans over one bad one is a collector people turn off.
Timestamps are nanoseconds since the Unix epoch. They exceed 253,
so OTLP/JSON sends them as strings, and mcpd parses them as strings. Reading
them as floating point loses precision, which shows up as spans that appear
to start microseconds apart when they did not. An
endTimeUnixNano of 0 or absent means the span has
not finished.
A traceparent header is
version-traceid-parentid-flags. mcpd parses it with the rules
the specification sets: version ff is refused, version
00 must have exactly four fields, later versions may carry
more, the ids must validate, and bit 0 of the flags byte is the sampled
flag. A malformed header is never a reason to refuse a request. mcpd starts
a new trace instead.
Where this matters: the mcpd SDKs read an inbound traceparent
and emit one on outbound calls, so a trace survives a service boundary. The
OTLP endpoints take their ids from the payload, because your exporter has
already put them in the spans. If a trace begins in a service you do not
instrument with mcpd, pass its traceparent along and both
halves land under one trace id.
One request, in full
Nothing above needs an SDK. This is a complete, valid export you can paste into a terminal:
raw OTLP/JSON
curl -X POST http://localhost:4317/v1/traces \
-H 'content-type: application/json' \
-d '{
"resourceSpans": [{
"resource": { "attributes": [
{ "key": "service.name", "value": { "stringValue": "checkout-agent" } }
]},
"scopeSpans": [{
"spans": [{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"name": "chat gpt-4o",
"startTimeUnixNano": "1700000000000000000",
"endTimeUnixNano": "1700000001500000000",
"status": { "code": 1 },
"attributes": [
{ "key": "gen_ai.system", "value": { "stringValue": "openai" } },
{ "key": "gen_ai.request.model", "value": { "stringValue": "gpt-4o" } },
{ "key": "gen_ai.operation.name", "value": { "stringValue": "chat" } },
{ "key": "gen_ai.usage.input_tokens", "value": { "intValue": "1200" } },
{ "key": "gen_ai.usage.output_tokens", "value": { "intValue": "350" } }
]
}]
}]
}]
}'
# {"partialSuccess":{"rejectedSpans":0,"errorMessage":""}} What OTLP cannot carry
OTLP has a shape for spans, logs and metrics. It has none for a machine score, a human's thumbs-down or an aspect note, and no vocabulary for the token detail a modern provider bills on. Two ways to send those:
-
POST /v1/ingest, mcpd's native batch endpoint, which carries the full data model in one request. -
POST /v1/api/scoresandPOST /v1/api/feedback, which attach to a trace you have already sent over OTLP.
Mixing the two is normal and expected. Export spans with the OpenTelemetry SDK you already run, and post scores from your evaluation job. They join on the trace id. See Scores and feedback and the HTTP API.
Before you expose the port
With no --ingest-key set, anyone who can reach 4317 can write
to it, and with no --read-key, read every stored prompt. mcpd
logs a warning at startup when it binds a non-loopback address with either
left open. Set them with
configuration, then send the
key as Authorization: Bearer, which is what
OTEL_EXPORTER_OTLP_HEADERS above does.
run with keys
mcpd --ingest-key $INGEST_KEY --read-key $READ_KEY