HTTP Trace Injection
Send traces to Neatlogs via plain HTTP — no SDK, no OpenTelemetry. Post one nested JSON trace from any language or runtime.
When the SDK isn't an option, send traces straight to Neatlogs over HTTP. This is useful when you want to:
- Use Neatlogs from a language with no SDK (Go, Ruby, Rust, shell, …)
- Integrate with custom frameworks or orchestration tools
- Send traces from external systems or agents that can't load the SDK
- Build a lightweight integration without any dependencies
The endpoint takes one nested JSON trace — you describe the span tree by nesting, and Neatlogs generates the trace and span IDs for you. (This is the same POST /v1/trace endpoint the Browser SDK uses; if you're in JavaScript, prefer that thin wrapper.)
Quick Start
Send a POST request to /v1/trace with a single trace object. Nesting via children expresses the hierarchy — no IDs to manage:
curl -X POST https://ingest.neatlogs.com/v1/trace \
-H "Content-Type: application/json" \
-H "x-api-key: <your-write-key>" \
-d '{
"name": "my-workflow",
"project": "my-project",
"children": [
{
"name": "answer",
"kind": "LLM",
"model": "gpt-4o",
"input": "What is the capital of France?",
"output": "Paris.",
"tokens": { "prompt": 12, "completion": 3 }
}
]
}'The root object is the trace — its name becomes the workflow name, and it's stored as the WORKFLOW root span. Everything in children nests underneath.
Authentication
Pass your write key in either header:
x-api-key: <your-write-key>Authorization: Bearer <your-write-key>Use a write key (nlw_…) — an ingest-only, per-user credential (it can POST traces but never read data, so it's safe to embed in browser / untrusted clients). Generate or rotate it from your account settings in Neatlogs (one key per user).
A write key identifies you, not a project, so you must name the target project with a root-level project field (the project's name) on every trace — see the Browser SDK.
Endpoint
Endpoint: POST /v1/trace
Base URL: https://ingest.neatlogs.com (the SDK default; point at your own host if self-hosting)
Full URL: https://ingest.neatlogs.com/v1/trace
This is the simple JSON receiver. For an OpenTelemetry exporter, point it at the OTLP route POST /v1/traces instead — that path speaks OTLP protobuf, not the JSON below.
Request Format
Send a single trace object. Every field except name is optional, and the same fields apply to the root and to any node in children:
{
"name": "string (required) — root name becomes the workflow name",
"project": "string (root only, required with a write key) — target project NAME",
"kind": "string (optional) — span kind; inferred from the fields when omitted",
"input": "any (optional) — step input",
"output": "any (optional) — step output",
"model": "string (optional) — LLM model name",
"tokens": { "prompt": 0, "completion": 0, "total": 0 },
"query": "any (optional) — retriever / reranker query",
"documents": "any (optional) — retriever / reranker results",
"tool_name": "string (optional) — tool / MCP tool identifier",
"passed": "boolean (optional) — guardrail result",
"score": "number (optional) — guardrail score",
"status": "OK | ERROR (optional, default OK)",
"error": "string (optional) — error message when status is ERROR",
"start": "string (optional) — ISO timestamp; defaults to the parent span's start",
"end": "string (optional) — ISO timestamp; defaults to start + duration_ms",
"duration_ms": "number (optional) — latency; end is derived from start + duration_ms",
"metadata": { "any": "arbitrary key/values" },
"attributes": { "neatlogs.llm.temperature": 0.7 },
"logs": [{ "level": "info", "message": "string", "timestamp": "ISO (optional)" }],
"children": [{ "name": "nested span", "kind": "TOOL" }]
}project is root-only. Send it once on the top-level trace object (not on children). It's required when authenticating with a write key and ignored on child spans.
No IDs. You never send span_id, parent_span_id, or a trace_id — Neatlogs generates them from the tree shape. Parent/child relationships come purely from how you nest children.
The convenience fields above are shortcuts for the most common attributes. For the full canonical surface, set any neatlogs.* key directly under attributes (e.g. neatlogs.agent.role, neatlogs.tool.parameters) — explicit attributes win over the shortcut fields. Keys outside the canonical namespace are dropped.
Response
Success (200 OK):
{
"success": true,
"trace_id": "a1b2c3d4e5f6...",
"spans": 2
}trace_id is the generated 32-character hex ID for the trace (use it to correlate); spans is the number of spans ingested.
Validation error (400):
{
"error": "Validation error",
"details": { "fieldErrors": { "name": ["Required"] } }
}Span Kinds
Set kind on any node, or omit it and let Neatlogs infer it from the fields present. Inference is checked in this order: tool_name → TOOL; passed/score → GUARDRAIL; model/tokens → LLM; query/documents → RETRIEVER; otherwise the root → WORKFLOW, a node with children → CHAIN, and a bare leaf → TOOL. An explicit kind always wins. The canonical kinds:
| Kind | Use for |
|---|---|
WORKFLOW | Top-level entry point (the root is always rooted as one) |
LLM | A language-model call (model + tokens) |
TOOL | An external tool / API call (tool_name) |
AGENT | An agent step |
CHAIN | A fixed sequence of steps |
TASK | A discrete unit of work |
RETRIEVER | A retrieval step (query + documents) |
EMBEDDING | Embedding generation |
VECTOR_STORE | A vector-store operation |
RERANKER | Document re-scoring |
GUARDRAIL | A safety / validation check (passed + score) |
EVALUATOR | A scoring / evaluation step |
MCP_TOOL | A tool called over the Model Context Protocol |
HTTP | A raw HTTP request |
Attributes by Span Kind
There are two ways to populate a span:
- Convenience fields (
model,tokens,query,tool_name, …) — the easy path. The backend translates them into the canonicalneatlogs.*attributes for you. Only some kinds have convenience fields (see below). - The
attributesobject — set any canonicalneatlogs.*key directly. This is required for anything the convenience fields don't cover (e.g.neatlogs.llm.temperature), and it's the only way to add detail to kinds that have no convenience fields (AGENT, CHAIN, WORKFLOW, TASK, EVALUATOR, VECTOR_STORE, HTTP). Keys outside theneatlogs.*namespace are dropped.
input and output work on every kind (stored as neatlogs.<kind>.input / .output). The sections below list what each kind accepts.
LLM
| Convenience field | Maps to |
|---|---|
model | neatlogs.llm.model_name |
tokens.prompt / .completion / .total | neatlogs.llm.token_count.prompt / .completion / .total |
input / output | neatlogs.llm.input / .output |
Common extras via attributes: neatlogs.llm.provider, neatlogs.llm.temperature, neatlogs.llm.top_p, neatlogs.llm.top_k, neatlogs.llm.max_tokens, neatlogs.llm.frequency_penalty, neatlogs.llm.presence_penalty, neatlogs.llm.finish_reason, neatlogs.llm.stop_sequences, neatlogs.llm.is_streaming, neatlogs.llm.system_prompt, neatlogs.llm.invocation_parameters, neatlogs.llm.response_id, neatlogs.llm.token_count.reasoning / .cache_read / .cache_write.
{
"name": "answer",
"kind": "LLM",
"model": "gpt-4o",
"input": "What is the capital of France?",
"output": "Paris.",
"tokens": { "prompt": 12, "completion": 3 },
"attributes": {
"neatlogs.llm.provider": "openai",
"neatlogs.llm.temperature": 0.7,
"neatlogs.llm.top_p": 0.9,
"neatlogs.llm.max_tokens": 256
}
}TOOL / MCP_TOOL
| Convenience field | Maps to |
|---|---|
tool_name | neatlogs.tool.name |
input / output | neatlogs.tool.input / .output (or neatlogs.mcp_tool.* for MCP_TOOL) |
Common extras via attributes: neatlogs.tool.description, neatlogs.tool.parameters (JSON schema string), neatlogs.tool.json_schema, neatlogs.tool.id.
{
"name": "lookup_account",
"kind": "TOOL",
"tool_name": "get_account",
"input": { "customer_id": "cust_123" },
"output": { "status": "active" },
"attributes": {
"neatlogs.tool.description": "Fetch a customer account by id",
"neatlogs.tool.parameters": "{\"customer_id\": \"string\"}"
}
}RETRIEVER
| Convenience field | Maps to |
|---|---|
query | neatlogs.retriever.query |
documents (array) | neatlogs.retriever.documents.0, .1, … |
input / output | neatlogs.retriever.input / .output |
Common extras via attributes: neatlogs.retriever.top_k.
{
"name": "retrieve_docs",
"kind": "RETRIEVER",
"query": "how do I reset my password?",
"documents": ["doc: reset flow", "doc: account recovery"],
"attributes": { "neatlogs.retriever.top_k": 5 }
}RERANKER
| Convenience field | Maps to |
|---|---|
query | neatlogs.reranker.query |
model | neatlogs.reranker.model_name |
documents (array) | neatlogs.reranker.input_documents.0, .1, … |
Common extras via attributes: neatlogs.reranker.top_k, neatlogs.reranker.output_documents.{i}.
{
"name": "rerank",
"kind": "RERANKER",
"query": "reset password",
"model": "cohere-rerank-v3",
"documents": ["doc1", "doc2", "doc3"],
"attributes": { "neatlogs.reranker.top_k": 2 }
}EMBEDDING
| Convenience field | Maps to |
|---|---|
model | neatlogs.embedding.model_name |
input | neatlogs.embedding.text |
Common extras via attributes: neatlogs.embedding.vector, neatlogs.embedding.token_count, neatlogs.embedding.invocation_parameters.
{
"name": "embed_query",
"kind": "EMBEDDING",
"model": "text-embedding-3-small",
"input": "how do I reset my password?",
"attributes": { "neatlogs.embedding.token_count": 8 }
}GUARDRAIL
| Convenience field | Maps to |
|---|---|
passed | neatlogs.guardrail.passed |
score | neatlogs.guardrail.score |
input / output | neatlogs.guardrail.input / .output |
Use the passed / score convenience fields (not the attributes object) — the dashboard reads neatlogs.guardrail.passed to show Passed / Blocked, and passed: false also marks the span as an error.
{
"name": "safety_check",
"kind": "GUARDRAIL",
"input": "user message to validate",
"passed": false,
"score": 0.82,
"output": "blocked: prompt injection detected"
}AGENT, CHAIN, WORKFLOW, TASK, EVALUATOR, VECTOR_STORE, HTTP
These kinds have no convenience fields beyond input / output — add detail through the attributes object with canonical keys:
- AGENT —
neatlogs.agent.name,neatlogs.agent.role - TASK —
neatlogs.task.id,neatlogs.task.key - WORKFLOW —
neatlogs.workflow.name - VECTOR_STORE —
neatlogs.vectordb.index_name,neatlogs.vectordb.embedding_model,neatlogs.vectordb.vector_dimension,neatlogs.vectordb.similarity_algorithm,neatlogs.vectordb.retrieval_query,neatlogs.vectordb.retrieval_documents
{
"name": "research_agent",
"kind": "AGENT",
"input": "Plan the response",
"output": "Drafted a 3-step plan",
"attributes": {
"neatlogs.agent.name": "researcher",
"neatlogs.agent.role": "Gathers and synthesizes sources"
}
}Sessions & end-users
Tag a trace with who it belongs to (the end-user of your app) and which conversation it's part of (the session). Set both as canonical attributes on the root object — they are ignored on child spans.
| Attribute | Meaning |
|---|---|
neatlogs.session.id | The session (conversation) id. Every trace sharing this value groups into one session. |
neatlogs.end_user.id | The end-user this conversation belongs to (the person using your app, not the operator). |
neatlogs.end_user.metadata | Arbitrary end-user fields, as a JSON string (e.g. "{\"plan\":\"pro\"}"). |
{
"name": "chat_turn",
"project": "my-app",
"attributes": {
"neatlogs.session.id": "conv_42",
"neatlogs.end_user.id": "u_812",
"neatlogs.end_user.metadata": "{\"plan\":\"pro\"}"
},
"children": [
{ "name": "answer", "kind": "LLM", "model": "gpt-4o", "input": "hi", "output": "hello" }
]
}The model: one turn = one trace; a session groups the turns of a conversation. To record a multi-turn chat, POST one trace per turn, each with the same neatlogs.session.id. A one-shot run is a session of a single trace. An end-user is per session — put the same neatlogs.end_user.id on every turn of the conversation.
Put identity on the root object only. If the root object carries a non-rootable kind (e.g. you set "kind": "LLM" on a single-node trace), Neatlogs still roots the trace correctly and keeps the identity — but placing neatlogs.session.id / neatlogs.end_user.id on child spans has no effect.
Once ingested, sessions appear on the Sessions page, and traces can be filtered by session and end-user. See Sessions and End-user identity.
Example: Python
import requests
BASE_URL = "https://ingest.neatlogs.com"
API_KEY = "your-write-key"
def send_trace(trace: dict):
res = requests.post(
f"{BASE_URL}/v1/trace",
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
json=trace,
)
if res.status_code == 200:
body = res.json()
print(f"Trace sent: {body['trace_id']} ({body['spans']} spans)")
else:
print(f"Error: {res.status_code} - {res.text}")
send_trace({
"name": "customer-support-agent",
"project": "my-project",
"input": {"question": "How do I reset my password?"},
"children": [
{
"name": "call_llm",
"kind": "LLM",
"model": "gpt-4o",
"input": "Generate a helpful response about password resets...",
"output": "Click 'Forgot Password' on the login page...",
"tokens": {"prompt": 240, "completion": 60},
},
{
"name": "lookup_account",
"kind": "TOOL",
"tool_name": "get_account",
"input": {"customer_id": "cust_123"},
"output": {"status": "active"},
},
],
})Example: JavaScript / TypeScript
async function sendTrace(trace: Record<string, unknown>) {
const res = await fetch("https://ingest.neatlogs.com/v1/trace", {
method: "POST",
headers: {
"x-api-key": process.env.NEATLOGS_WRITE_KEY ?? "",
"Content-Type": "application/json",
},
body: JSON.stringify(trace),
});
if (res.ok) {
const { trace_id, spans } = await res.json();
console.log(`Trace sent: ${trace_id} (${spans} spans)`);
} else {
console.error(`Error: ${res.status} - ${await res.text()}`);
}
}
await sendTrace({
name: "my-agent",
project: "my-project",
input: { query: "What is the weather?" },
children: [
{
name: "call_weather_api",
kind: "TOOL",
tool_name: "get_weather",
input: { location: "San Francisco" },
output: { temperature: 72, condition: "sunny" },
},
],
});In JavaScript runtimes, the Browser SDK wraps exactly this call (nl.trace({ name, children })) with a typed API and safe error handling.
Nested Spans
Express hierarchy by nesting children — Neatlogs wires up the parent/child links and assigns IDs:
{
"name": "workflow",
"children": [
{
"name": "step_one",
"kind": "CHAIN",
"children": [
{ "name": "nested_tool", "kind": "TOOL", "tool_name": "search" }
]
}
]
}Error Spans
Mark a failed step with status: "ERROR" and an error message:
{
"name": "api_call",
"kind": "TOOL",
"tool_name": "weather_api",
"status": "ERROR",
"error": "Connection timeout after 30s"
}Logs on a Span
Attach structured logs to any node with logs — they render as LOG children in the timeline:
{
"name": "rag_pipeline",
"kind": "CHAIN",
"logs": [
{ "level": "info", "message": "retrieved 42 documents" },
{ "level": "warning", "message": "low top score: 0.41" }
]
}Timestamps
Timestamps are optional ISO 8601 strings. The easiest approach is to skip them and just pass duration_ms per span — the backend anchors the trace so it ends at ingest time and derives each end from start + duration_ms. A child span with no explicit start inherits its parent's start. Pass explicit start/end only when you have absolute times:
// Easiest: pass a duration, skip start/end
{ "name": "step", "duration_ms": 1200 }
// Or explicit ISO timestamps
{ "name": "step", "start": "2026-06-05T14:32:10Z", "end": "2026-06-05T14:32:15Z" }When to Use HTTP vs. SDK
| Method | Best for | Pros | Cons |
|---|---|---|---|
| SDK (Python / TS) | Native applications | Auto-instrumentation, decorators, easy setup | Language-specific |
HTTP /v1/trace | Any language / runtime | Language-agnostic, zero dependencies | Manual span tree |
| Browser SDK | Front-end JavaScript | Typed wrapper over /v1/trace, never throws | JS only |
Limits
- Max payload: 50 MB per request (larger payloads are rejected).
- Latency: traces typically appear within 1–2 seconds.
- Batch a whole run as one nested trace rather than posting spans individually.
See Also
- Browser SDK — a typed JavaScript wrapper over this same endpoint
- Span Kinds — the complete span-type reference
- Custom Attributes — the
neatlogs.*attribute namespace
