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

Note

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" }]
}
Note

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.

Note

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_nameTOOL; passed/scoreGUARDRAIL; model/tokensLLM; query/documentsRETRIEVER; otherwise the root → WORKFLOW, a node with childrenCHAIN, and a bare leaf → TOOL. An explicit kind always wins. The canonical kinds:

KindUse for
WORKFLOWTop-level entry point (the root is always rooted as one)
LLMA language-model call (model + tokens)
TOOLAn external tool / API call (tool_name)
AGENTAn agent step
CHAINA fixed sequence of steps
TASKA discrete unit of work
RETRIEVERA retrieval step (query + documents)
EMBEDDINGEmbedding generation
VECTOR_STOREA vector-store operation
RERANKERDocument re-scoring
GUARDRAILA safety / validation check (passed + score)
EVALUATORA scoring / evaluation step
MCP_TOOLA tool called over the Model Context Protocol
HTTPA raw HTTP request

Attributes by Span Kind

There are two ways to populate a span:

  1. Convenience fields (model, tokens, query, tool_name, …) — the easy path. The backend translates them into the canonical neatlogs.* attributes for you. Only some kinds have convenience fields (see below).
  2. The attributes object — set any canonical neatlogs.* 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 the neatlogs.* 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 fieldMaps to
modelneatlogs.llm.model_name
tokens.prompt / .completion / .totalneatlogs.llm.token_count.prompt / .completion / .total
input / outputneatlogs.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 fieldMaps to
tool_nameneatlogs.tool.name
input / outputneatlogs.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 fieldMaps to
queryneatlogs.retriever.query
documents (array)neatlogs.retriever.documents.0, .1, …
input / outputneatlogs.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 fieldMaps to
queryneatlogs.reranker.query
modelneatlogs.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 fieldMaps to
modelneatlogs.embedding.model_name
inputneatlogs.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 fieldMaps to
passedneatlogs.guardrail.passed
scoreneatlogs.guardrail.score
input / outputneatlogs.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:

  • AGENTneatlogs.agent.name, neatlogs.agent.role
  • TASKneatlogs.task.id, neatlogs.task.key
  • WORKFLOWneatlogs.workflow.name
  • VECTOR_STOREneatlogs.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.

AttributeMeaning
neatlogs.session.idThe session (conversation) id. Every trace sharing this value groups into one session.
neatlogs.end_user.idThe end-user this conversation belongs to (the person using your app, not the operator).
neatlogs.end_user.metadataArbitrary 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.

Note

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

MethodBest forProsCons
SDK (Python / TS)Native applicationsAuto-instrumentation, decorators, easy setupLanguage-specific
HTTP /v1/traceAny language / runtimeLanguage-agnostic, zero dependenciesManual span tree
Browser SDKFront-end JavaScriptTyped wrapper over /v1/trace, never throwsJS only

Limits

Note
  • 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

On this page

Ask Neatlogs AI

Answers from the docs

How can I help?

Ask anything about instrumenting, tracing, or the Neatlogs dashboard.