Skip to main content

OTLP Trace Ingestion

All endpoints verified working via curl. Document updated only after curl confirmation.

This endpoint exists for frameworks that export traces over the OpenTelemetry (OTel) wire protocol rather than calling AcruxCore's native POST /api/v1/traces JSON shape directly. CrewAI, LangChain, and LlamaIndex all ship an openinference-instrumentation-* package that does this automatically — pointing one at AcruxCore is an environment-variable change, not a code change:

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://api.acruxcore.com/api/v1/traces/otlp
OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=Bearer <acrux-key>

Use the signal-specific _TRACES_ variable above, not the generic OTEL_EXPORTER_OTLP_ENDPOINT — the OTel spec auto-appends /v1/traces to the generic one, which would misroute to /api/v1/v1/traces. The signal-specific variable is used verbatim.

For a full worked example, see Trace a CrewAI Trip-Planning Crew or Trace an OpenAI Agents SDK Support-Triage System.

There is no SDK wrapper for this endpoint in @acruxcoreai/sdk or acruxcore (Python) — its entire purpose is interop with other frameworks' own OTel exporters, not something our own SDKs call.


POST /api/v1/traces/otlp​

Accepts one OTLP ExportTraceServiceRequest, protobuf (Content-Type: application/x-protobuf, the default for every OTel HTTP exporter) or JSON (Content-Type: application/json), optionally gzip-compressed (Content-Encoding: gzip, also an OTel exporter default). Same auth as the native endpoint — a session cookie, personal API key, or virtual key (requireAnyAuthOrVirtualKey). Internally this is a thin translation layer in front of the same IngestService the native JSON endpoint uses: it decodes the OTLP body, maps each span's attributes, and stores it exactly like a native span. Two attribute vocabularies are mapped: OpenInference (the attribute set CrewAI, LangChain, and LlamaIndex all use) and the OpenTelemetry GenAI semantic conventions (gen_ai.*). When a span carries both, OpenInference wins.

llm.token_count.prompt_details.cache_read becomes the span's cachedTokens, so cached prompt tokens are billed at the model's cached input price. An LLM span with llm.token_count.prompt 1891, llm.token_count.completion 42 and cache_read 1856, on a model priced at $1.40 input, $0.26 cached input and $4.40 output per 1M tokens, was stored at cost_usd 0.000716360.

A request body here is protobuf-encoded binary, so it can't be inlined as JSON like the native endpoint's docs. The curl below sends a file produced by a small Node script built for this verification, using protobufjs against the vendored .proto files at apps/api/src/traces/ingest/otlp/proto/ — the same files the decoder itself loads. Its key lines:

const Type = root.lookupType('opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest');
const message = Type.create({
resourceSpans: [{
resource: { attributes: [{ key: 'service.name', value: { stringValue: 'crewai-trip-planner' } }] },
scopeSpans: [{
spans: [{
traceId: Buffer.from('1a2b3c4d5e6f70819293a4b5c6d7e8f9', 'hex'),
spanId: Buffer.from('aabbccddeeff0011', 'hex'),
name: 'CrewAgentExecutor.invoke',
startTimeUnixNano: '1700000000000000000',
endTimeUnixNano: '1700000000500000000',
attributes: [
{ key: 'openinference.span.kind', value: { stringValue: 'AGENT' } },
{ key: 'input.value', value: { stringValue: 'Plan a 3-day trip to Rome' } },
{ key: 'output.value', value: { stringValue: 'Day 1: Colosseum. Day 2: Vatican. Day 3: Trastevere.' } },
],
status: { code: 1 }, // OTel STATUS_CODE_OK
}],
}],
}],
});
fs.writeFileSync('sample-otlp-request.bin', Buffer.from(Type.encode(message).finish()));
curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/x-protobuf" \
--data-binary @sample-otlp-request.bin \
-i

Response (status 200) — an empty body, per the OTLP spec's ExportTraceServiceResponse:

{}

The span landed exactly like a native one. GET /api/v1/traces/:id for the UUID form of the encoded trace_id (1a2b3c4d5e6f70819293a4b5c6d7e8f9 → 1a2b3c4d-5e6f-7081-9293-a4b5c6d7e8f9) confirms it:

{
"trace": {
"id": "1a2b3c4d-5e6f-7081-9293-a4b5c6d7e8f9",
"status": "ok",
"spanCount": 1
},
"spans": [
{
"spanId": "aabbccddeeff0011",
"kind": "agent",
"name": "CrewAgentExecutor.invoke",
"status": "ok",
"attributes": {
"openinference.span.kind": "AGENT"
},
"payload": {
"input": "Plan a 3-day trip to Rome",
"output": "Day 1: Colosseum. Day 2: Vatican. Day 3: Trastevere.",
"variables": null
}
}
]
}

openinference.span.kind: "AGENT" mapped to AcruxCore's kind: "agent", and input.value/output.value populated the span's payload — not attributes. attributes only ever holds the raw OTel attributes that aren't payload-bearing (here, just openinference.span.kind itself): input.value, output.value, and their OpenInference siblings (llm.input_messages.*, llm.output_messages.*, retrieval.documents.*) are stripped out and promoted into payload instead, so the team's payload capture setting and redaction rules apply to them exactly once, through the same channel a native POST /api/v1/traces call uses.


The trace's name comes from its root span​

A trace's name is what the trace list and the session detail view show, so it has to say what ran. It is taken from the batch's root span — the span with no parentSpanId — with a trailing run id trimmed off. CrewAI's Crew_<uuid> therefore becomes Crew, which also means two runs of the same crew share a name and can be found by it.

Posting the two-span batch above under a root named Crew_5f1fcf48-de64-42cf-b927-5c060836053:

curl $ACRUXCORE_BASE_URL/traces/b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"
{
"trace": {
"id": "b1b1b1b1-b1b1-b1b1-b1b1-b1b1b1b1b1b1",
"name": "Crew",
"sessionId": "crewai-probe-session",
"status": "ok",
"spanCount": 2
}
}

Root means no parent at all, not "no parent in this batch." OTel's BatchSpanProcessor flushes a finished leaf span before its still-open parent, so a span whose parent is merely absent from this export is usually a child, and naming the run after it would name the run after one of its leaves. A batch holding no parentless span therefore contributes no name, and the trace keeps the default — its started_at timestamp — until the root arrives.

When the root does arrive in a later batch, it replaces that timestamp. This only ever replaces the timestamp default: a name you set yourself, through POST /api/v1/traces or an x-trace-name header on a gateway call, is never overwritten by a derived one.


RETRIEVER spans: retrieved documents land in payload.output​

OpenInference flattens a RETRIEVER span's retrieved documents into indexed attributes — retrieval.documents.{i}.document.content, .document.score, and so on — rather than putting them in output.value (which, on a retriever, is usually absent; input.value is just the search query). This endpoint reassembles those indexed attributes into an ordered array and promotes it into payload.output, the same way output.value would be promoted on any other span kind. Verified by posting a LlamaIndex-shaped VectorIndexRetriever.retrieve span with two documents:

curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/x-protobuf" \
--data-binary @sample-otlp-retriever.bin \
-i

Response (status 200):

{}
curl $ACRUXCORE_BASE_URL/traces/cafebabe-0000-cafe-babe-00000000babe \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"
{
"trace": {
"id": "cafebabe-0000-cafe-babe-00000000babe",
"status": "ok",
"spanCount": 1
},
"spans": [
{
"spanId": "aa11bb22cc33dd44",
"kind": "retrieval",
"name": "VectorIndexRetriever.retrieve",
"status": "ok",
"attributes": {
"openinference.span.kind": "RETRIEVER"
},
"payload": {
"input": "what is the refund policy?",
"output": [
{ "score": 0.91, "content": "Refunds are issued within 30 days of purchase." },
{ "score": 0.77, "content": "Store credit is offered after the 30-day window." }
],
"variables": null
}
}
]
}

kind: "retrieval" (from openinference.span.kind: "RETRIEVER"), payload.input is the search query from input.value, and payload.output is the two retrieved documents in score order — reassembled from retrieval.documents.0.document.* / retrieval.documents.1.document.* and stripped out of attributes just like every other payload-bearing key.


gzip-compressed body​

The same request, gzipped, with Content-Encoding: gzip set:

gzip -c sample-otlp-request.bin > sample-otlp-request.bin.gz
curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/x-protobuf" \
-H "Content-Encoding: gzip" \
--data-binary @sample-otlp-request.bin.gz \
-i

Response (status 200):

{}

Re-fetching the trace afterward still shows spanCount: 1 — this was the same (traceId, spanRef) pair retried, and the endpoint's retry-safe upsertSpan() absorbed it without creating a duplicate. This matters because the OTLP spec expects exporters to retry a whole batch on any non-2xx response or network failure.


Error responses​

No Authorization header (status 401):

{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required." } }

Body is not a valid ExportTraceServiceRequest (status 400, not 500):

curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/x-protobuf" \
--data-binary "not a valid protobuf message" \
-i
{ "error": { "code": "VALIDATION_ERROR", "message": "Malformed OTLP request: invalid wire type 6 at offset 1" } }

Chunking a large batch (>200 spans)​

OTel exporters default to batches of up to 512 spans; AcruxCore's ingestion caps at 200 spans per call to IngestService.ingest(). This endpoint chunks a decoded request into ≤200-span groups automatically, transparent to the exporter. Verified by posting one trace with 250 spans:

curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/x-protobuf" \
--data-binary @sample-otlp-250-spans.bin \
-i

Response (status 200):

{}
curl $ACRUXCORE_BASE_URL/traces/99999999-9999-9999-9999-999999999999 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"
{
"trace": {
"id": "99999999-9999-9999-9999-999999999999",
"status": "ok",
"spanCount": 250
},
"spans": [
{ "spanId": "0000000000000000", "kind": "chain", "name": "chain-step-0", "status": "ok" }
]
}

trace.spanCount is 250 and the trace's spans array returns all 250 — no protocol-level rejection, no truncation, despite exceeding the 200-span native cap in a single OTLP request.


OpenTelemetry GenAI semantic conventions​

A span that uses the official GenAI semantic conventions is mapped too. This is the vocabulary of opentelemetry-instrumentation-openai-v2 and OpenLLMetry.

GenAI attributeAcruxCore field
gen_ai.operation.namekind: chat, text_completion, generate_content → llm; embeddings → embedding; execute_tool → tool; invoke_agent, create_agent → agent; invoke_workflow → chain; retrieval → retrieval; anything else → other
gen_ai.response.model, else gen_ai.request.modelmodel
gen_ai.provider.name, else gen_ai.system, else the provider the model name impliesprovider
gen_ai.usage.input_tokens / gen_ai.usage.output_tokens (or the older prompt_tokens / completion_tokens)promptTokens / completionTokens; totalTokens is their sum. Not read on agent and chain spans. An embedding span with only input tokens gets completionTokens 0
gen_ai.usage.cache_read.input_tokens (or gen_ai.usage.cache_read_input_tokens)cachedTokens
gen_ai.conversation.idthe trace's sessionId
gen_ai.input.messages, gen_ai.system_instructions, gen_ai.prompt, gen_ai.prompt.N.*, gen_ai.tool.call.arguments, gen_ai.retrieval.query.text, traceloop.entity.inputpayload.input
gen_ai.output.messages, gen_ai.completion, gen_ai.completion.N.*, gen_ai.tool.call.result, gen_ai.retrieval.documents, traceloop.entity.outputpayload.output

The content keys in the last two rows go to payload, never to attributes, so your payload-capture setting and redaction apply to them. When a span carries more than one of them, payload.input (or output) holds all of them, keyed by attribute name.

A span with a model and both token counts is priced like any other span. Agent and workflow spans get no token counts, because the conventions put the sum of their child LLM calls there, and counting it again would double the trace's tokens and cost.

curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"resourceSpans": [{
"resource": { "attributes": [] },
"scopeSpans": [{
"spans": [{
"traceId": "75757575757575757575757575757575",
"spanId": "7575757575757575",
"name": "chat gpt-4o-mini",
"startTimeUnixNano": "1700000000000000000",
"endTimeUnixNano": "1700000000400000000",
"attributes": [
{ "key": "gen_ai.operation.name", "value": { "stringValue": "chat" } },
{ "key": "gen_ai.provider.name", "value": { "stringValue": "openai" } },
{ "key": "gen_ai.request.model", "value": { "stringValue": "gpt-4o-mini" } },
{ "key": "gen_ai.usage.input_tokens", "value": { "intValue": "1000" } },
{ "key": "gen_ai.usage.output_tokens", "value": { "intValue": "200" } },
{ "key": "gen_ai.conversation.id", "value": { "stringValue": "conv-42" } },
{ "key": "gen_ai.input.messages", "value": { "stringValue": "[{\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"content\":\"What is OTLP?\"}]}]" } }
]
}]
}]
}]
}'

Response (status 200):

{}
curl $ACRUXCORE_BASE_URL/traces/75757575-7575-7575-7575-757575757575 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"

Response (status 200, trimmed to the fields this section is about):

{
"trace": {
"id": "75757575-7575-7575-7575-757575757575",
"name": "chat gpt-4o-mini",
"sessionId": "conv-42",
"spanCount": 1,
"totalCostUsd": 0.00027,
"totalTokens": 1200
},
"spans": [
{
"spanId": "7575757575757575",
"kind": "llm",
"name": "chat gpt-4o-mini",
"model": "gpt-4o-mini",
"provider": "openai",
"promptTokens": 1000,
"completionTokens": 200,
"totalTokens": 1200,
"costUsd": 0.00027,
"attributes": {
"gen_ai.provider.name": "openai",
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.operation.name": "chat",
"gen_ai.conversation.id": "conv-42",
"gen_ai.usage.input_tokens": 1000,
"gen_ai.usage.output_tokens": 200
},
"payload": {
"input": "[{\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"content\":\"What is OTLP?\"}]}]",
"output": null
}
}
]
}

Google ADK's legacy span schema​

Google ADK's older span schema is the default in releases before 2.4. Later releases send it when ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=1 is set. In this schema the model call is a span named call_llm, with gen_ai.system set to gcp.vertex.agent and no gen_ai.operation.name. It is mapped as follows:

ADK attribute or span nameAcruxCore field
span name call_llmkind llm
span name execute_tool …, tool_call […] or tool_response …kind tool
gen_ai.system = gcp.vertex.agentnever stored as provider; the provider comes from the model name instead
gcp.vertex.agent.llm_request, gcp.vertex.agent.tool_call_args, gcp.vertex.agent.datapayload.input
gcp.vertex.agent.llm_response, gcp.vertex.agent.tool_responsepayload.output

ADK writes {} into these content keys when content capture is off or the key does not apply to the span. That value is dropped, not stored as content.

A call_llm span gets no token counts. ADK 1.24 and later also send a generate_content child span for the same model call, with the same counts. That child is the span that is counted and priced, so the trace total is not doubled. The counts stay visible in the call_llm span's attributes.

curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"resourceSpans": [{
"resource": { "attributes": [] },
"scopeSpans": [{
"spans": [{
"traceId": "77737773777377737773777377737773",
"spanId": "7773777377737773",
"name": "call_llm",
"startTimeUnixNano": "1700000000000000000",
"endTimeUnixNano": "1700000000900000000",
"attributes": [
{ "key": "gen_ai.system", "value": { "stringValue": "gcp.vertex.agent" } },
{ "key": "gen_ai.request.model", "value": { "stringValue": "gemini-3.8-flash" } },
{ "key": "gen_ai.usage.input_tokens", "value": { "intValue": "93" } },
{ "key": "gen_ai.usage.output_tokens", "value": { "intValue": "59" } },
{ "key": "gcp.vertex.agent.llm_request", "value": { "stringValue": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Lahore?\"}]}]}" } },
{ "key": "gcp.vertex.agent.llm_response", "value": { "stringValue": "{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Sunny, 24C.\"}]}}" } }
]
}]
}]
}]
}'

Response (status 200):

{}
curl $ACRUXCORE_BASE_URL/traces/77737773-7773-7773-7773-777377737773 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"

Response (status 200, trimmed to the fields this section is about):

{
"trace": {
"id": "77737773-7773-7773-7773-777377737773",
"name": "call_llm",
"spanCount": 1,
"totalCostUsd": null,
"totalTokens": 0
},
"spans": [
{
"spanId": "7773777377737773",
"kind": "llm",
"name": "call_llm",
"model": "gemini-3.8-flash",
"provider": "gemini",
"promptTokens": null,
"completionTokens": null,
"totalTokens": null,
"costUsd": null,
"attributes": {
"gen_ai.system": "gcp.vertex.agent",
"gen_ai.request.model": "gemini-3.8-flash",
"gen_ai.usage.input_tokens": 93,
"gen_ai.usage.output_tokens": 59
},
"payload": {
"input": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Lahore?\"}]}]}",
"output": "{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Sunny, 24C.\"}]}}",
"variables": null
}
}
]
}

Unmapped attribute vocabularies​

A span with neither openinference.span.kind nor a recognized gen_ai.* attribute is still stored — nothing from an unrecognized framework is silently dropped. Verified by posting a span whose only attribute is an arbitrary custom.thing key:

curl -X POST $ACRUXCORE_BASE_URL/traces/otlp \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/x-protobuf" \
--data-binary @sample-otlp-unmapped.bin \
-i

Response (status 200):

{}
curl $ACRUXCORE_BASE_URL/traces/77777777-7777-7777-7777-777777777777 \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"
{
"trace": { "id": "77777777-7777-7777-7777-777777777777", "status": "ok", "spanCount": 1 },
"spans": [
{
"spanId": "8888888888888888",
"kind": "other",
"name": "custom.step",
"status": "unset",
"attributes": { "custom.thing": "no recognized vocabulary here" }
}
]
}

kind fell back to "other", status fell back to "unset" (no status code was recognized either), and the raw custom.thing attribute is preserved verbatim in attributes — nothing dropped, no semantic fields invented.


This endpoint accepts OTLP/HTTP only; gRPC transport is not implemented. Every OTel instrumentation package relevant here (openinference-instrumentation-crewai, -langchain, -llama-index) defaults to HTTP, so this is a scope statement about what's built, not a behavior confirmed by a curl call the way the responses above are.