GuidesSend traces

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

EndpointsPOST /v1/traces (OTLP protobuf + JSON), POST /ingest/openinference, POST /ingest/raw, POST /ingest/batch
AuthAuthorization: Bearer nk_live_… agent API key (Settings → API keys)
Default response202 Accepted — parsing and persistence happen asynchronously
CompressionContent-Encoding: gzip or deflate accepted on every endpoint
Size limits1 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_here

The 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:

  1. The API key’s agent — always wins when a key is supplied.
  2. X-Neens-Project-Id header — only consulted when no key was supplied (keyless local/dev setups).
  3. project_id field in the request body — same keyless fallback, lowest priority.
  4. 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 setupUseEndpoint
Already using OpenTelemetryOTLPPOST /v1/traces
Using OpenInference instrumentationOpenInferencePOST /ingest/openinference
No tracing yet / custom pipelineRaw JSONPOST /ingest/raw
Many traces in one requestBatchPOST /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.

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.py

Standard exporters append /v1/traces to the endpoint automatically.

A few OTLP-specific behaviors, all standards-compliant:

  • The response is the OTLP ExportTraceServiceResponse envelope (protobuf or {} JSON, matching the request encoding) — 202 on 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 traceId and assembles one trace per id. The agent name comes from the resource’s service.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 413 includes a Retry-After header 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.

FieldRequiredDefaultDescription
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_atnow / —ISO-8601 timestamps (e.g. 2026-07-16T10:00:00Z). A malformed value is a 422 naming the field.
duration_ms0Total trace duration in milliseconds.
input_tokens / output_tokens0Token usage for cost roll-ups.
turn_count0Number 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.

FieldRequiredDefaultDescription
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_atsession startISO-8601 timestamp.
duration_ms0Span 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.

FieldRequiredDefaultDescription
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_ms0Call 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, or thread.id as 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:

  1. The edge authenticates the request, resolves the agent from the credential, and runs cheap structural validation (required top-level fields, size caps).
  2. The payload is durably buffered and the request returns 202 immediately — for the /ingest/* endpoints the body is {"batch_id": "ing_…", "accepted": true}; /v1/traces answers with the standard OTLP response envelope instead.
  3. 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.
  4. If the buffer is unavailable the request is not acknowledged — you get 503 with Retry-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

StatusMeaning
202 AcceptedTrace 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 RequestInvalid OTLP JSON/protobuf, or a corrupt compressed body.
401 UnauthorizedInvalid, 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 LargeBody 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 TypeUnsupported Content-Encoding (use gzip, deflate, or none).
422 Unprocessable EntityMalformed 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 RequestsBackpressure — 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 UnavailableThe 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)
FieldKeys probed, in order
Modelgen_ai.response.model → gen_ai.request.model → llm.model_name
Input tokensgen_ai.usage.input_tokens → gen_ai.usage.prompt_tokens → llm.token_count.prompt
Output tokensgen_ai.usage.output_tokens → gen_ai.usage.completion_tokens → llm.token_count.completion
Conversation IDgen_ai.conversation.id → session.id → conversation.id → thread.id
Span kindopeninference.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 messagesllm.input_messages.{i}.message.role / .content (indexed, in order) → gen_ai.prompt (JSON message list, or plain text) → input.value (raw fallback)
Output messagesllm.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 messageOTLP: gen_ai.error.message → span status message · OpenInference: exception.message → error.message
Agent nameOTLP: 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.

KeyPurpose
neens.version_labelStamps the trace with the agent version that produced it — surfaced in trace metadata and used to ground pre-prod comparisons.
neens.eval_run_idCorrelates a replayed trace with a pre-prod evaluation run.
neens.dataset_item_idPins the trace to the exact frozen golden prompt within that run.
Endpoint size limits and encodings
EndpointFormatsMax size (decompressed)
POST /v1/tracesOTLP protobuf (application/x-protobuf), OTLP JSON4 MiB
POST /ingest/openinferenceJSON ({"spans": [...]})1 MiB
POST /ingest/rawJSON ({"session": …, "spans": […], "tool_calls": […]})1 MiB
POST /ingest/batchJSON array of raw items, or {"items": [...]}8 MiB
POST /ingest/otlpOTLP 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

SymptomLikely causeFix
401 on every requestKey typo’d, revoked, or missing — or the server requires auth and you sent noneRe-copy the key; send it as Authorization: Bearer nk_live_…
202 but nothing in the UILooking at the wrong agent, or async processing hasn’t finishedSwitch to the key’s agent; wait a few seconds; test with ?sync=true
422 with a field nameMalformed payload — the detail says exactly which fieldFix the named field (e.g. add session.id, use ISO-8601 timestamps)
429 under loadYour agent’s ingest queue is saturatedBack off for Retry-After seconds and retry; batch smaller
Tokens/model missing on spansAttributes not in a recognized vocabularyUse 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.