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
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 attribute | AcruxCore field |
|---|---|
gen_ai.operation.name | kind: 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.model | model |
gen_ai.provider.name, else gen_ai.system, else the provider the model name implies | provider |
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.id | the 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.input | payload.input |
gen_ai.output.messages, gen_ai.completion, gen_ai.completion.N.*, gen_ai.tool.call.result, gen_ai.retrieval.documents, traceloop.entity.output | payload.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 name | AcruxCore field |
|---|---|
span name call_llm | kind llm |
span name execute_tool …, tool_call […] or tool_response … | kind tool |
gen_ai.system = gcp.vertex.agent | never 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.data | payload.input |
gcp.vertex.agent.llm_response, gcp.vertex.agent.tool_response | payload.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.