Send traces
Neens ingests your agent’s traces over HTTP. It speaks standard OpenTelemetry (OTLP) and OpenInference, and also accepts a simple Neens-native raw JSON format. Pick whichever matches how your agent is already instrumented — all paths land in the same trace store.
Already using Langfuse or Braintrust? You don’t have to re-instrument. A trace inlet pulls your existing traces into Neens on a schedule — keep your tracing tool, add the Neens brain.
At a glance
| Endpoints | POST /v1/traces (OTLP protobuf + JSON), POST /ingest/openinference, POST /ingest/raw, POST /ingest/batch |
| Auth | Authorization: Bearer nk_live_… agent API key (Settings → API keys) |
| Default response | 202 Accepted — parsing and persistence happen asynchronously |
| Compression | Content-Encoding: gzip or deflate accepted on every endpoint |
| Size limits | 1 MiB per single request · 4 MiB OTLP · 8 MiB batch |
| Sync mode | ?sync=true processes in-request and returns 200 with the persisted session |
Authentication
Every request should authenticate with an agent API key (nk_live_…) as a bearer token:
Authorization: Bearer nk_live_your_key_hereThe key alone scopes the request: it resolves to exactly one company and agent, so you don’t need to send an agent ID — and an authenticated request cannot be redirected to another agent by any header or body field. Create keys in Settings → API keys (see Getting started).
How a trace is attributed to an agent, in precedence order:
- The API key’s agent — always wins when a key is supplied.
X-Neens-Project-Idheader — only consulted when no key was supplied (keyless local/dev setups).project_idfield in the request body — same keyless fallback, lowest priority.- Otherwise the trace lands in the server’s default agent.
A bad credential is always rejected. A supplied credential that doesn’t resolve — an
edited, invalid, or revoked key, or an unrecognized bearer token — returns 401 Unauthorized.
It is never silently accepted, so a trace can never land in the wrong agent because of a typo
in the key.
Whether a request with no credential at all is accepted depends on the deployment, which
derives it from its storage backend: a Postgres deployment requires a key (keyless requests
get 401), while a SQLite single-tenant dev install still accepts keyless ingest.
An admin can override that in either direction for your deployment.
Upgrading a Postgres install that used to post keyless? See A keyless request started returning 401 below.
Base URL. Replace https://<your-neens-host> in the examples with your Neens URL. In the
app, the Traces page shows ready-to-copy snippets with your host and key already filled in.
Choosing a format
| Your setup | Use | Endpoint |
|---|---|---|
| Already using OpenTelemetry | OTLP | POST /v1/traces |
| Using OpenInference instrumentation | OpenInference | POST /ingest/openinference |
| No tracing yet / custom pipeline | Raw JSON | POST /ingest/raw |
| Many traces in one request | Batch | POST /ingest/batch |
All endpoints transparently decompress Content-Encoding: gzip (or deflate) bodies — the
default for OTLP exporters and collectors. zstd is not supported and returns
415 Unsupported Media Type; fall back to gzip.
OpenTelemetry (recommended)
If your agent is instrumented with OpenTelemetry, point its OTLP/HTTP exporter at Neens and add your key as a header. No Neens-specific code required.
Using a framework? The framework quickstarts have copy-paste OpenTelemetry config for LangGraph, CrewAI, OpenAI Agents SDK, Pydantic AI, Claude Agent SDK, and the Vercel AI SDK — install the instrumentation, point it here, done.
POST /v1/traces accepts both OTLP encodings:
- protobuf (
Content-Type: application/x-protobuf) — the exporter default, - JSON (
Content-Type: application/json) — the OTLP/JSON shape.
export OTEL_EXPORTER_OTLP_ENDPOINT="https://<your-neens-host>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer nk_live_your_key_here"
python my_agent.pyStandard exporters append /v1/traces to the endpoint automatically.
A few OTLP-specific behaviors, all standards-compliant:
- The response is the OTLP
ExportTraceServiceResponseenvelope (protobuf or{}JSON, matching the request encoding) —202on the default async path, so a stock exporter or collector is happy without any Neens-specific handling. - One OTLP export may carry spans from many traces (that’s how exporters batch); Neens
groups spans by
traceIdand assembles one trace per id. The agent name comes from the resource’sservice.name. - An export with no
resourceSpans(e.g. a collector heartbeat) is acknowledged as a successful no-op. - The OTLP payload cap is 4 MiB (a
413includes aRetry-Afterheader so collectors back off and split batches).
Neens reads model, inputs/outputs, messages, token usage, tool calls, and conversation IDs from standard OTel GenAI and OpenInference attributes — per field, first match wins — so mixed instrumentation resolves correctly. See the attribute reference below.
OpenInference
If you export OpenInference span dicts directly (rather than over OTLP), post them to
/ingest/openinference. The payload is an object with a top-level spans array; spans from
multiple traces may be mixed in one payload and are grouped by context.trace_id.
curl -X POST "https://<your-neens-host>/ingest/openinference" \
-H "Authorization: Bearer nk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"spans": [{
"context": {"trace_id": "trace-001", "span_id": "span-1"},
"name": "ChatCompletion",
"parent_id": null,
"start_time": "2026-07-16T10:00:00Z",
"end_time": "2026-07-16T10:00:02Z",
"status_code": "OK",
"attributes": {
"openinference.span.kind": "LLM",
"llm.model_name": "gpt-4o",
"llm.token_count.prompt": 120,
"llm.token_count.completion": 35,
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "Where is my order?",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "Let me check that for you.",
"session.id": "chat-42",
"service.name": "support-bot"
}
}]
}'Each span needs context.trace_id, context.span_id, start_time, and end_time
(ISO-8601). status_code is OK, ERROR, or unset; on errors, exception.message or
error.message in attributes becomes the span’s error message. The agent name is taken from
a service.name attribute. Spans with openinference.span.kind: "TOOL" also produce tool-call
records automatically (tool name = span name, args/result from input.value/output.value).
OpenInference and OTel GenAI are attribute vocabularies, not different wire formats — if your
OpenInference instrumentation already exports over OTLP, just use /v1/traces. Both endpoints
resolve both vocabularies with the same per-field logic.
Raw JSON (Neens-native)
The simplest option when you’re not using a tracing SDK. Post a session (one trace) and its
spans directly. Only session.id is required — everything else has sensible defaults.
curl -X POST "https://<your-neens-host>/ingest/raw" \
-H "Authorization: Bearer nk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"session": {"id": "trace-001", "agent_name": "my-agent"},
"spans": [
{"id": "span-1", "name": "llm.call", "kind": "llm",
"input": "Hello?", "output": "Hi!", "model": "gpt-4o",
"input_tokens": 12, "output_tokens": 4}
]
}'Raw payload reference
session — the trace header. Fields you supply are taken verbatim.
| Field | Required | Default | Description |
|---|---|---|---|
id | ✅ | — | Unique trace ID. Re-sending the same ID updates the trace (idempotent), never duplicates it. |
agent_name | "" | Name of the agent that produced the trace. | |
conversation_id | — | Groups multiple traces into one session (see below). | |
status | "ok" | ok or error. | |
started_at / ended_at | now / — | ISO-8601 timestamps (e.g. 2026-07-16T10:00:00Z). A malformed value is a 422 naming the field. | |
duration_ms | 0 | Total trace duration in milliseconds. | |
input_tokens / output_tokens | 0 | Token usage for cost roll-ups. | |
turn_count | 0 | Number of conversational turns. | |
metadata | {} | Arbitrary key/value object, usable for filtering. | |
source | "raw" | Free-form label for where the trace came from. |
spans — the steps inside the trace (optional array). Only id is required per span.
| Field | Required | Default | Description |
|---|---|---|---|
id | ✅ | — | Unique span ID. |
name | "" | Span name (e.g. llm.call, retrieve). | |
kind | "custom" | llm, agent, tool, chain, retrieval, guardrail, or custom. | |
parent_id | — | Parent span ID, to nest spans into a tree. | |
input / output | — | The span’s input and output (string; message lists as JSON). | |
model | — | Model name for LLM spans. | |
input_tokens / output_tokens | — | Per-span token usage. | |
status | "ok" | ok or error; add error_message on failures. | |
error_message | — | Error detail for failed spans. | |
started_at | session start | ISO-8601 timestamp. | |
duration_ms | 0 | Span duration in milliseconds. | |
attributes | {} | Flat key/value dict of semantic-convention attributes (see below). |
Attribute fallback. When a top-level span field (input, output, model,
input_tokens, output_tokens, kind) is absent, Neens fills it from the span’s
attributes using the same OTel-GenAI/OpenInference resolvers as the OTLP path. So an
OpenInference-shaped span (data under input.value, llm.model_name, llm.token_count.*,
openinference.span.kind) keeps its semantics through /ingest/raw too. Fields you set
explicitly always win.
tool_calls — optional array of tool/function invocations.
| Field | Required | Default | Description |
|---|---|---|---|
span_id | ✅ | — | The span this call belongs to. |
tool_name | ✅ | — | Name of the tool/function. |
args | {} | Arguments passed (object). | |
result | — | Return value. | |
error | — | Error string if the call failed. | |
duration_ms | 0 | Call duration in milliseconds. |
Batch
To send many traces in one request, post an array of raw payloads — or {"items": [...]} — to
/ingest/batch. All items are attributed to the API key’s agent and buffered as one unit.
curl -X POST "https://<your-neens-host>/ingest/batch" \
-H "Authorization: Bearer nk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '[
{"session": {"id": "trace-001", "agent_name": "my-agent"}, "spans": []},
{"session": {"id": "trace-002", "agent_name": "my-agent"}, "spans": []}
]'A batch may be up to 8 MiB; each item is validated up front — a missing session or
session.id returns 422 naming the offending item (e.g.
batch item[1] session is missing required field 'id'). The batch must be non-empty.
With ?sync=true the batch is processed in-request and returns
200 {"session_ids": [...], "session_count": n, "span_count": n, "warnings": [...]}.
Grouping traces into sessions
Neens rolls individual traces up into a session when they share a conversation_id. Set
the same ID on each turn of a multi-turn conversation and the turns appear together on the
Sessions page, while remaining individually visible under Traces.
- Raw JSON: set
session.conversation_id. - OTLP / OpenInference: set any of
gen_ai.conversation.id,session.id,conversation.id, orthread.idas a span attribute (first one found across the trace’s spans wins; empty strings are ignored).
{"session": {"id": "turn-2", "conversation_id": "chat-42", "agent_name": "support-bot"}}A trace with no conversation ID is simply its own single-trace session. See Core concepts for the trace/session distinction.
How it works
Ingestion is asynchronous by default so your agent’s latency never depends on the Neens write path:
- The edge authenticates the request, resolves the agent from the credential, and runs cheap structural validation (required top-level fields, size caps).
- The payload is durably buffered and the request returns
202immediately — for the/ingest/*endpoints the body is{"batch_id": "ing_…", "accepted": true};/v1/tracesanswers with the standard OTLP response envelope instead. - A worker parses the payload (one session per trace ID), resolves semantic-convention attributes, and persists spans and tool calls. Traces appear in the UI within a few seconds.
- If the buffer is unavailable the request is not acknowledged — you get
503withRetry-After, and your exporter/collector retries from its own queue. Nothing is silently dropped.
Re-sending a trace is safe: sessions and spans are keyed by their IDs and upserted, so retries
and duplicate deliveries update rather than duplicate. You may also supply your own
Idempotency-Key header; otherwise a hash of the payload is used.
Retrying safely. On a 429 the response carries a Retry-After header — honor it and back off
exponentially rather than hammering the edge. Re-sending a batch under the same Idempotency-Key
is deduplicated: the ingest worker reads the key and skips a batch it has already processed (a cheap
skip instead of a re-parse), so a client-side retry never double-counts. The dedup window is 7 days
by default.
Add ?sync=true to any ingest endpoint to process in-request instead — useful for tests,
first-trace verification, and low-volume backfills:
curl -X POST "https://<your-neens-host>/ingest/raw?sync=true" ...
# → 200 {"session_id": "trace-001", "span_count": 1, "warnings": []}In sync mode you also get immediate 422s for deeper semantic problems (e.g. a malformed
timestamp) that the async path would only detect after acceptance.
Reference
Response codes
| Status | Meaning |
|---|---|
202 Accepted | Trace accepted and queued (default). /ingest/* return {"batch_id": …, "accepted": true}; /v1/traces returns the OTLP response envelope. |
200 OK | ?sync=true — processed in-request; body includes the persisted session_id(s) and span_count. Also a no-op OTLP export (empty resourceSpans). |
400 Bad Request | Invalid OTLP JSON/protobuf, or a corrupt compressed body. |
401 Unauthorized | Invalid, edited, or revoked API key; unrecognized bearer token — or a keyless request when the server requires auth (the default on self-hosted Postgres; see A keyless request started returning 401). Check the Authorization: Bearer nk_live_… header. |
413 Payload Too Large | Body exceeds the size limit — 1 MiB single (/ingest/otlp, /ingest/openinference, /ingest/raw), 4 MiB OTLP (/v1/traces), 8 MiB batch. Limits apply to the decompressed size, so gzip can’t smuggle an oversized payload. |
415 Unsupported Media Type | Unsupported Content-Encoding (use gzip, deflate, or none). |
422 Unprocessable Entity | Malformed payload — the detail names the offending field, e.g. missing required field 'resourceSpans', session is missing required field 'id', span[2]: raw span is missing required field 'id', invalid span started_at timestamp '…': expected ISO-8601. |
429 Too Many Requests | Backpressure — your agent’s ingest queue is saturated. Honor the Retry-After header (default 5 seconds) and retry; standard collectors/SDKs do this automatically. |
503 Service Unavailable | The ingest buffer is temporarily unavailable. Not an ack — retry after Retry-After. |
Backpressure is per-agent: only the saturated agent is throttled, so a burst on one agent
never slows another. The Retry-After value defaults to 5 seconds.
Which attributes Neens reads
Neens resolves each semantic field by probing the OTel GenAI and OpenInference vocabularies per field, in order — first non-empty value wins. Mixing vocabularies within one payload, or even one span, is fine.
Full attribute table (OTel GenAI + OpenInference)
| Field | Keys probed, in order |
|---|---|
| Model | gen_ai.response.model → gen_ai.request.model → llm.model_name |
| Input tokens | gen_ai.usage.input_tokens → gen_ai.usage.prompt_tokens → llm.token_count.prompt |
| Output tokens | gen_ai.usage.output_tokens → gen_ai.usage.completion_tokens → llm.token_count.completion |
| Conversation ID | gen_ai.conversation.id → session.id → conversation.id → thread.id |
| Span kind | openinference.span.kind (LLM/AGENT/TOOL/CHAIN/RETRIEVAL/GUARDRAIL) → gen_ai.operation.name (tool/function → tool, embeddings → retrieval, chat etc. → llm) → heuristic (gen_ai.tool.name present → tool; a model attribute present → llm) → custom |
| Input messages | llm.input_messages.{i}.message.role / .content (indexed, in order) → gen_ai.prompt (JSON message list, or plain text) → input.value (raw fallback) |
| Output messages | llm.output_messages.{i}.message.role / .content → gen_ai.completion → output.value |
| Tool name (tool spans) | gen_ai.tool.name → span name |
| Tool args / result (tool spans) | span input / output (input.value / output.value on OpenInference) — JSON strings are parsed |
| Error message | OTLP: gen_ai.error.message → span status message · OpenInference: exception.message → error.message |
| Agent name | OTLP: resource service.name · OpenInference: span attribute service.name |
Message content cleanup. Message content values may be plain strings, serialized
LangChain messages (unwrapped to the inner content), or lists of content blocks (concatenated).
Neens normalizes them all to clean {role, content} lists.
Serialized-blob fallback. Some frameworks (LangChain/LangGraph LLM nodes) bury the model
name and token usage inside the serialized output blob instead of span attributes. When the
attribute chains above come up empty, Neens parses that blob and recovers
model_name/token_usage/usage_metadata best-effort. Attributes always win when present.
Neens-specific attributes.
| Key | Purpose |
|---|---|
neens.version_label | Stamps the trace with the agent version that produced it — surfaced in trace metadata and used to ground pre-prod comparisons. |
neens.eval_run_id | Correlates a replayed trace with a pre-prod evaluation run. |
neens.dataset_item_id | Pins the trace to the exact frozen golden prompt within that run. |
Endpoint size limits and encodings
| Endpoint | Formats | Max size (decompressed) |
|---|---|---|
POST /v1/traces | OTLP protobuf (application/x-protobuf), OTLP JSON | 4 MiB |
POST /ingest/openinference | JSON ({"spans": [...]}) | 1 MiB |
POST /ingest/raw | JSON ({"session": …, "spans": […], "tool_calls": […]}) | 1 MiB |
POST /ingest/batch | JSON array of raw items, or {"items": [...]} | 8 MiB |
POST /ingest/otlp | OTLP JSON only (legacy; prefer /v1/traces) | 1 MiB |
All accept Content-Encoding: gzip or deflate. The compressed body is also capped at the
same limit, and decompression bombs are rejected with 413.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 on every request | Key typo’d, revoked, or missing — or the server requires auth and you sent none | Re-copy the key; send it as Authorization: Bearer nk_live_… |
202 but nothing in the UI | Looking at the wrong agent, or async processing hasn’t finished | Switch to the key’s agent; wait a few seconds; test with ?sync=true |
422 with a field name | Malformed payload — the detail says exactly which field | Fix the named field (e.g. add session.id, use ISO-8601 timestamps) |
429 under load | Your agent’s ingest queue is saturated | Back off for Retry-After seconds and retry; batch smaller |
| Tokens/model missing on spans | Attributes not in a recognized vocabulary | Use the keys in the attribute table, or set top-level fields on raw spans |
A keyless request started returning 401
Ingest that used to be accepted with no credential now fails. This happens on a self-hosted Postgres deployment: a keyless write is refused rather than being filed into the unscoped default agent. (SQLite single-tenant dev installs are unaffected — keyless ingest still works there.)
The request that now fails:
curl -i -X POST "https://<your-neens-host>/ingest/raw" \
-H "Content-Type: application/json" \
-d '{"session": {"id": "trace-001"}, "spans": []}'and the response:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"detail": "Ingest requires an API key. Provide 'Authorization: Bearer nk_live_…'."}The fix: send an agent API key. Mint one in the app under Settings → API keys — click Generate key and copy it immediately, it is shown once (full walkthrough: API keys). Then send it as a bearer token:
curl -i -X POST "https://<your-neens-host>/ingest/raw" \
-H "Authorization: Bearer nk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"session": {"id": "trace-001"}, "spans": []}'Expect 202 Accepted with a batch_id. The same header works on /v1/traces,
/ingest/openinference and /ingest/batch — for an OTLP exporter or collector, set it via
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer nk_live_your_key_here".
The key carries the agent scope, so once you send one you can drop any X-Neens-Project-Id
header or project_id body field you were relying on — the key wins over both.
Still stuck? See My traces aren’t showing up in the FAQ.
Verify
Open Traces in the app. Your traces appear within a few seconds; click one to inspect its spans, tool calls, inputs, outputs, latency, and cost. Once traces flow, continue to Traces & sessions and Judges.