Ingest reference.
The full contract for sending traces to TwoTail. For a guided setup, start with the quickstart.
Endpoint
POST https://www.twotail.ai/api/v1/traces X-API-Key: <TWOTAIL_API_KEY> Content-Type: application/json
The body is standard OTLP/JSON (the OpenTelemetry OTLP/HTTP JSON encoding). OTLP protobuf, OTLP/gRPC, and gzip-compressed bodies are not accepted. API keys are created in the app under API Keys and sent in the X-API-Key header.
Response
{
"success": true,
"message": "Successfully ingested 12 spans from OTel format",
"spans_inserted": 12
}
Spans that are valid OTLP but fail TwoTail's mapping are skipped, not rejected: the request still succeeds and the skip count usually shows in message as "(N malformed spans skipped)". spans_inserted is the authoritative count. A span missing required fields (traceId, spanId, name, timestamps) fails request validation instead, and the whole request is rejected with 422.
Errors
| Status | When |
|---|---|
401 | The X-API-Key header is missing, or the key is malformed, revoked, or expired. |
400 | More than 1,000 spans in one request. The whole batch is rejected; split it and resend. |
422 | The body isn't valid OTLP/JSON, e.g. a span missing required fields. The whole request is rejected. |
429 | The account hit a cap: for example, a batch that would push an account pending approval past its 10,000 stored-span limit. The whole batch is rejected; the response body says which limit applies. Contact support@twotail.ai to lift the pending-approval limit. |
Retries
Retrying a batch is safe. A span resent with the same spanId refreshes that span's outputs, end time, tokens, cost, and error status instead of duplicating it; its name, trace, parent, start time, inputs, and metadata keep their first-write values. Span ids must therefore be unique across all your traces, not just within one trace: reusing an id for a different span corrupts the stored record.
Payload shape
{
"resourceSpans": [{
"resource": {
"attributes": [
{"key": "service.name", "value": {"stringValue": "my-agent"}}
]
},
"scopeSpans": [{
"spans": [{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"parentSpanId": null,
"name": "chat_completion",
"startTimeUnixNano": 1783977600000000000,
"endTimeUnixNano": 1783977600100000000,
"attributes": [
{"key": "gen_ai.system", "value": {"stringValue": "openai"}}
],
"status": {"code": 1}
}]
}]
}]
}
| Field | Notes |
|---|---|
traceId | 32 lowercase hex chars. One trace id per agent run; every span in the run shares it. |
spanId | 16 lowercase hex chars, unique per span. |
parentSpanId | Links a child span to its parent. Null or omitted for root spans. |
startTimeUnixNano / endTimeUnixNano | Integer Unix time in nanoseconds. Millisecond or second values are accepted without error but land decades in the past. |
attributes | OTLP key-value pairs. Values use typed wrappers: stringValue, intValue, doubleValue, boolValue, arrayValue, kvlistValue. |
status.code | 0 unset, 1 ok, 2 error. Only 2 marks the span as an error. |
| resource attributes | Preserved on each span of the batch, prefixed resource., e.g. resource.service.name. |
Span typing
TwoTail assigns each span a type and, where it can, a purpose. Both drive filtering, cost attribution, and analysis.
| Type | Assigned when |
|---|---|
llm | gen_ai.system or gen_ai.request.model is present, or the span name contains "llm" or "chat". |
tool | tool.name is present, or the span name contains "tool". |
run | Everything else (agent steps, orchestration, custom spans). |
Rules are checked in order: gen_ai attributes, then tool.name, then name keywords.
| Purpose | Assigned when the span name |
|---|---|
evaluation | starts with eval. or eval_, or contains "evaluat". |
planning | contains "plan". |
generation | contains "generat" or "complet". |
retrieval | contains "search" or "retriev". |
Attributes TwoTail reads
TwoTail understands several instrumentation conventions side by side. Send whichever your stack produces; you don't need to translate between them.
| Key | What it carries |
|---|---|
| OpenTelemetry GenAI conventions | |
gen_ai.system | Provider, e.g. "openai", "anthropic". Marks the span as an LLM call. |
gen_ai.request.model | Model name. Also drives cost when no explicit cost is sent. |
gen_ai.prompt / gen_ai.input.messages | The input prompt or structured message list. |
gen_ai.completion / gen_ai.output.messages | The output completion or structured messages. |
gen_ai.request.temperature, gen_ai.request.max_tokens | Request parameters. |
gen_ai.response.finish_reasons, gen_ai.response.id | Response metadata. |
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens | Token counts. |
gen_ai.usage.cached_input_tokens | Cached input tokens, counted as a subset of input tokens. |
gen_ai.usage.cost | Explicit cost in USD. A nonzero value overrides computed cost. |
| Tool calls | |
tool.name | Tool being called. Marks the span as a tool call. |
tool.parameters / tool.result | Tool input and output. |
| Vercel AI SDK telemetry | |
ai.model.id | Model name. |
ai.prompt, ai.prompt.messages | Prompt and message list. |
ai.response.text, ai.response.toolCalls, ai.response.finishReason | Response content. |
ai.toolCall.args / ai.toolCall.result | Tool call input and output. |
ai.usage.cachedInputTokens | Cached input tokens. |
| OpenInference (e.g. LlamaIndex instrumentation) | |
input.value / output.value | Span input and output. |
llm.input_messages / llm.output_messages | Structured messages. |
llm.invocation_parameters | Request parameters. |
llm.token_count.prompt / llm.token_count.completion | Token count fallbacks. |
| OpenLLMetry | |
traceloop.entity.input / traceloop.entity.output | Entity input and output. |
Values for these keys that arrive as stringified JSON are parsed into structured objects, so message lists and tool arguments render properly in the trace view. The exception is gen_ai.prompt, which is stored verbatim: prefer gen_ai.input.messages for structured message lists.
Everything else
Attributes TwoTail doesn't specifically read are preserved as span metadata and stay queryable in analysis: business ids like user.id or document.id, feature flags, whatever you send. Values keep the type you sent them with. One consequence worth knowing: a flag sent as the string "1" is compared as a string, not the number 1, when you later ask questions about it.
Sessions
For multi-turn agents, TwoTail groups traces into sessions by conversation id: one trace per turn, the same conversation id on every turn. Set gen_ai.conversation.id on the spans of each turn.
Aliases accepted, first match wins: gen_ai.conversation.id, session.id, session_id, sessionId, conversation.id, conversation_id, thread.id, thread_id.
Evals
Send evaluations as child spans of the span they evaluate: set the eval span's parentSpanId to the evaluated span's id.
- Naming. Start the span name with
eval.oreval_(e.g.eval.relevance), or use a name containing "evaluation" or "evaluate", so the span gets purposeevaluation. - Scores. Use the OTel GenAI eval conventions:
gen_ai.evaluation.name,gen_ai.evaluation.score.value(numeric),gen_ai.evaluation.score.label(e.g. "pass"),gen_ai.evaluation.explanation. Legacyeval.name/eval.score/eval.passed/eval.reasonare still accepted. - LLM judges. If an LLM produces the evaluation, include
gen_ai.*attributes on the eval span so the judge call itself is captured with model, tokens, and cost.
Cost
Cost is attributed to the exact LLM span that spent it; it is not rolled up onto parents, so grouping and slicing spend stays accurate at any level.
- Explicit wins. If you send a nonzero
gen_ai.usage.cost, that value is used as-is; a cost of 0 falls back to the computed estimate. - Otherwise computed. Cost is derived from token counts when
gen_ai.request.modelmatches a model in TwoTail's pricing table of known models. The match is exact: a provider-specific alias or dated snapshot name that isn't recognized yields no cost rather than a wrong guess. - Cached tokens priced. Cached input tokens (see attributes) are priced at the provider's cached rate where TwoTail has one for the model, otherwise at a default of 10% of the input rate.
- Recommendation. Send canonical model names, or send
gen_ai.usage.costexplicitly if you use gateways or exotic model ids.
Gotchas
- Errors are status-code only. A span is an error if and only if
status.codeis2. Exception text in the name or attributes does not mark it. - Nanoseconds. Timestamps in milliseconds or seconds are accepted silently and produce 1970-era spans.
- Token values must be numeric. Malformed token counts coerce to 0 rather than erroring.
- Strings compare as strings. Custom flags sent as
"0"/"1"strings are compared as text in analysis, not as numbers. - Span ids must be unique across all your traces. Resending a span id refreshes the original span; reusing one for a different span corrupts it.
- Watch
spans_inserted. It is the authoritative count of what landed; don't rely on the message text alone.
Spot a problem on this page? Email us.