Skip to content
mcpd
no daemon
Source

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.

Ingest pipeline
  1. Decode

    OTLP or native, gzip or plain

  2. Sample

    Decided once, at the root

  3. Redact

    Before anything is written

  4. Price

    From the configured price book

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

  1. mcpd.span.kind, when it names a kind mcpd knows.
  2. openinference.span.kind.
  3. gen_ai.operation.name.
  4. The presence of an attribute that only one kind of span carries.
Attribute value mcpd kind
openinference.span.kind=LLMmodel_generation
openinference.span.kind=CHAINworkflow_run
openinference.span.kind=TOOLtool_call
openinference.span.kind=AGENTagent_run
openinference.span.kind=RETRIEVERretrieval
openinference.span.kind=EMBEDDINGrag_embedding
openinference.span.kind=RERANKERrag_action
openinference.span.kind=GUARDRAILguardrail
openinference.span.kind=EVALUATORscorer_run
gen_ai.operation.name=chatmodel_generation
gen_ai.operation.name=text_completionmodel_generation
gen_ai.operation.name=generate_contentmodel_generation
gen_ai.operation.name=embeddingsrag_embedding
gen_ai.operation.name=execute_tooltool_call
gen_ai.operation.name=invoke_agentagent_run
gen_ai.operation.name=create_agentagent_run
any of gen_ai.system, llm.model_name, llm.provider presentmodel_generation
mcp.tool.name presentmcp_tool_call
http.request.method or http.method presenthttp_request
anything elsegeneric

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/scores and POST /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