Browser SDK
Send traces to Neatlogs directly from front-end web apps with the zero-dependency browser client.
The browser SDK (neatlogs/browser) is a tiny, zero-dependency client for sending traces straight from a web app. It has only fetch, so it bundles cleanly into front-end code. It POSTs plain JSON to the backend, which generates the trace and span IDs, builds the hierarchy from your nesting and infers cost from model + tokens.
Use a write key, not your project key. Browser code is public, and a project key can read your traces as well as send them. Use your personal write key (nlw_…) instead: open Settings → Project → API keys, find the HTTP ingest key section, and select Generate key (or Reveal key to copy an existing one). It is ingest-only: it can send traces to /v1/trace but never read data. It is personal and works for any project in your organization, so pass the target project's name as project. If it leaks, select Rotate key.
Install
npm install neatlogsQuick start
EU projects: add endpoint: 'https://eu.ingest.neatlogs.com' to new Neatlogs({...}). Use the base URL, without /v1/trace.
import { Neatlogs } from 'neatlogs/browser';
const nl = new Neatlogs({
apiKey: 'nlw_your_write_key', // ingest-only write key
project: 'my-web-app', // your project NAME (required with a write key)
});
// One AI interaction → a one-span LLM trace
await nl.trackAI({
name: 'chat',
model: 'gpt-4o',
input: 'What is the capital of France?',
output: 'Paris.',
tokens: { prompt: 12, completion: 3 },
});That's the whole setup — the call renders as a trace in your dashboard. Telemetry never throws into your app: transport errors go to onError (default console.warn).
Sending a nested trace
Pass a tree of spans via children; the backend wires up the parent/child hierarchy. The root node's name becomes the workflow name.
await nl.trace({
name: 'support-chat',
children: [
{ name: 'retrieve', kind: 'RETRIEVER', query: userQuestion, documents: docs },
{ name: 'answer', model: 'gpt-4o', input: prompt, output: reply,
tokens: { prompt: 240, completion: 60 } },
],
});Streaming: start then finish
When the output arrives incrementally, open the trace up front and complete it when done — nothing is sent until finish():
const t = nl.startTrace({ name: 'chat', model: 'gpt-4o', input: prompt });
// … stream tokens …
await t.finish({ output: fullText, tokens: { prompt: 20, completion: 130 } });Span fields
Each span node accepts these shortcut fields (all optional except name):
| Field | Type | For |
|---|---|---|
name | string | Required. The span label (root name = workflow name). |
kind | span kind | Optional — the backend infers it from the fields present when omitted. |
input / output | unknown | The step's input and output. |
model | string | LLM model name. |
tokens | { prompt, completion, total } | Token usage. |
query / documents | unknown | Retriever / reranker query and results. |
tool_name | string | Tool / MCP tool identifier. |
passed / score | boolean / number | Guardrail result. |
duration_ms | number | Latency (simplest way — end is derived from start + duration_ms). |
metadata | object | Arbitrary metadata. |
attributes | object | Escape hatch — any canonical neatlogs.* attribute (e.g. { "neatlogs.llm.temperature": 0.7 }). Explicit attributes win over the shortcut fields. |
children | span[] | Nested spans. |
logs | { level, message, timestamp }[] | Structured logs on the span. |
endUserId | string | Root only. The end-user this conversation belongs to (overrides the client default). One end-user per session — stamp the same id on every turn's trace root. |
endUserMetadata | object | Root only. Arbitrary end-user fields (e.g. { plan: 'pro' }), overrides the client default. |
sessionId | string | Root only. The session (conversation) this trace belongs to (overrides the client default). A one-shot run is one trace; a multi-turn conversation is one trace per turn, all sharing the same sessionId. |
Constructor options
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | — | Required. Your write key (nlw_…), not a project API key. |
project | string | — | Required. Target project name. |
endpoint | string | https://ingest.neatlogs.com | Backend base URL; set to https://eu.ingest.neatlogs.com for EU projects. |
enabled | boolean | true | Set false to validate calls without sending. |
onError | (err) => void | console.warn | Called on transport errors instead of throwing. |
endUserId | string | — | Default end-user (the person using your app) for every trace. Overridden by a per-call endUserId. |
endUserMetadata | object | — | Default arbitrary end-user fields (e.g. { plan: 'pro' }). |
sessionId | string | — | Default session (conversation) for every trace. Overridden by a per-call sessionId. Set it per conversation. |
Sessions & end-users
Identify who each trace belongs to and which conversation it's part of. Set defaults on the client, or override per call — only the trace root carries them.
// A chatbot: one Neatlogs client per conversation, tagged with the user + session.
const nl = new Neatlogs({
apiKey: 'nlw_...',
project: 'my-app',
endUserId: 'u_812', // who — the person in this conversation
endUserMetadata: { plan: 'pro' },
sessionId: conversationId, // which conversation — the same id for every turn
});
// Each turn is its own trace; all turns share `sessionId`, so the dashboard groups
// them into one multi-turn conversation.
await nl.trackAI({ name: 'chat', model: 'gpt-4o', input: turn1, output: reply1 });
await nl.trackAI({ name: 'chat', model: 'gpt-4o', input: turn2, output: reply2 });
// Or per call (overrides the client defaults):
await nl.trackAI({
name: 'chat', model: 'gpt-4o', input, output,
endUserId: 'u_999', sessionId: 'conv_42',
});The browser SDK uses the same field names as the Node/Python SDKs (endUserId, endUserMetadata, sessionId). An end-user is per session — a session (conversation) belongs to one person; stamp the same endUserId on every turn. See Sessions and End-user identity for the full model and how to filter by them.
Under the hood the browser SDK calls the same POST /v1/trace endpoint documented in the HTTP API — use that directly from any language or runtime that isn't JavaScript.
