# TwoTail ingest reference

The full contract for sending traces to TwoTail. For a guided setup, start
with the quickstart: https://www.twotail.ai/docs

- HTML version: https://www.twotail.ai/docs/reference
- Last updated: 2026-07-10

## 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 TwoTail app under API Keys and sent in the
`X-API-Key` header.

### Response

```json
{
  "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

```json
{
  "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.

### OpenTelemetry GenAI conventions

| Key | What it carries |
|---|---|
| `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

| Key | What it carries |
|---|---|
| `tool.name` | Tool being called. Marks the span as a tool call. |
| `tool.parameters` / `tool.result` | Tool input and output. |

### Vercel AI SDK telemetry

| Key | What it carries |
|---|---|
| `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)

| Key | What it carries |
|---|---|
| `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

| Key | What it carries |
|---|---|
| `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.` or `eval_` (e.g.
  `eval.relevance`), or use a name containing "evaluation" or "evaluate",
  so the span gets purpose `evaluation`.
- **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`. Legacy `eval.name` /
  `eval.score` / `eval.passed` / `eval.reason` are 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.model` matches 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 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.cost` explicitly if you use gateways or exotic model ids.

## Gotchas

- **Errors are status-code only.** A span is an error if and only if
  `status.code` is 2. 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.
