TypeScript SDK

Install, initialize, and instrument TypeScript/Node.js LLM and agent apps with Neatlogs.

The TypeScript SDK installs from npm as neatlogs. Use the latest published stable release. Wrap the AI clients you already use, add spans to your own functions, and track prompt templates — with an idiomatic, fully-async TypeScript API. This page covers everything from install to the full init() reference; use the contents on the right to jump around.

Note

New to tracing? A trace is the record of one run of your app; a span is one step inside it (an LLM call, a tool call). You instrument once, then read traces in the dashboard. See Introduction.

Install

npm install neatlogs@latest

The package supports Node.js >=18; CI compatibility baselines include Node.js 18, 20, and 22. Install the provider/framework package your application uses, then attach the explicit Neatlogs wrapper, handler, processor, hook, or plugin listed under Supported libraries.

Check the installed SDK with Doctor

From the application root, choose the command for the detected package manager and run the network-free check before changing instrumentation:

npm exec --offline --no -- neatlogs doctor --local --json

After instrumenting and running your normal checks, set the project key through your environment or secret manager and run the authenticated probe separately:

npm exec --offline --no -- neatlogs doctor --probe --json

These commands select the installed package's platform-specific executable, including its Windows shim, without downloading another package. Require Doctor v2 format, TypeScript runtime, and schema 2. Compare the reported SDK version with the installed project package. If the package lacks Doctor v2, check npm for the latest published stable release and ask before upgrading. Accept newer compatible releases and never downgrade one. A local pass validates controlled in-process capture only. A probe pass additionally proves exact finalized readback of its own four-span trace, but neither replaces testing your real workflow. See SDK Doctor for the full pass criteria, reason codes, and safe troubleshooting steps.

Your first trace

init() is async — always await it. Then wrap your client with the matching wrap* helper.

import { init, wrapOpenAI, flush, shutdown } from 'neatlogs';
import OpenAI from 'openai';

async function main() {
  await init({ apiKey: process.env.NEATLOGS_API_KEY, workflowName: 'my-first-app' });

  const client = wrapOpenAI(new OpenAI());

  const res = await client.chat.completions.create({
    model: 'gpt-4o',
    messages: [{ role: 'user', content: 'What is the capital of France?' }],
  });
  console.log(res.choices[0].message.content);

  await flush();
  await shutdown();
}

main().catch(console.error);

Run it with tsx, ts-node, or after compiling — a trace appears under my-first-app within a few seconds. The parentless LLM span is a valid trace root, so wrapOpenAI does not add a blank WORKFLOW wrapper. Standalone non-root operations such as TOOL receive one automatic workflow parent.

Note

A wrap* helper patches the specific client you pass, so import order never matters. Other integrations use an explicit handler, processor, hook, or plugin. See Supported Libraries for the exact mechanism and Integrations for runnable examples.

The async-main rule

init(), flush(), and shutdown() are all async. Most setups don't allow top-level await, so wrap your program in an async function main().

There is no init-order rule to remember: the wrap* helpers patch the specific client instance you hand them, so a static top-of-file import is fine.

import OpenAI from 'openai';                 // hoisted — doesn't matter
import { init, wrapOpenAI } from 'neatlogs';

await init({ apiKey: process.env.NEATLOGS_API_KEY });
const client = wrapOpenAI(new OpenAI());     // traced
Warning

Forgetting await shutdown() (or await flush()) before a short-lived script exits is the most common reason traces don't appear. Long-running servers export continuously — call flush() + shutdown() once on server shutdown. Serverless handlers should flush each invocation without shutting down a warm runtime. See Flush and Shutdown.

Use the lifecycle that matches the process:

  • Scripts and CLI tools: await flush() and shutdown() in finally.
  • Long-running services, workers, and stream consumers: initialize once, let background batching run, stop accepting work during graceful shutdown, then await flush() and shutdown() once. Do not flush per request, job, or stream item.
  • Serverless handlers: end every span and await flush() before returning. Await shutdown() only when the runtime itself is being torn down so a warm runtime remains reusable.
  • Tests: await flush() before assertions that depend on exported spans and await shutdown() in suite teardown so state does not leak into another test.

For streamed responses, end the model and workflow spans when the stream is fully consumed, fails, or is cancelled; a flush cannot finish an open span.

Instrument your own code with span()

span() is a higher-order function: pass it your function and it returns an instrumented version with the same signature. Each call creates a span capturing inputs, output, timing, and errors.

import { span } from 'neatlogs';

const handleRequest = span({ kind: 'WORKFLOW' }, async (userInput: string) => {
  return await supportAgent(userInput);
});

const supportAgent = span({ kind: 'AGENT', name: 'support_agent' }, async (message: string) => {
  // ...
});

const getOrderStatus = span({ kind: 'TOOL', toolName: 'get_order_status' }, async (orderId: string) => {
  return ordersDb.get(orderId);
});

Because each wrapper nests under whatever span is active when it runs, the trace tree mirrors your real call hierarchy.

Note

Grouping calls into one trace. A single wrap*-ed call renders on its own — the wrap* helpers (wrapOpenAI, wrapAnthropic, wrapBedrock, wrapVertexAI, wrapAzureOpenAI, wrapGoogleGenAI, wrapOpenRouterAgent) open a WORKFLOW root automatically. But each call with no surrounding context becomes its own trace, so when a run makes several calls (or mixes them with your own functions), wrap the entry point with span({ kind: 'WORKFLOW' }, …) — it becomes the single root and everything nests under it.

Note

Running several independent features in one process? Give each its own named WORKFLOW root at its entry point so they appear as distinct workflows in the dashboard — see Multiple Workflows in One Codebase.

@Span decorator (class methods)

For class methods, use the TC39 Stage 3 decorator (TypeScript 5.0+, experimentalDecorators: false):

import { Span } from 'neatlogs';

class ResearchAgent {
  @Span({ kind: 'AGENT', role: 'researcher' })
  async run(query: string) {
    return await this.search(query);
  }

  @Span({ kind: 'TOOL', toolName: 'web-search' })
  async search(query: string) {
    return { results: ['...'] };
  }
}

SpanOptions

OptionDescription
kindRequired. The span kind (see Span kinds).
nameSpan label. Defaults to the function name.
captureInput / captureOutputCapture args / return value (default true).
role, goalAgent role and objective (kind: 'AGENT').
toolName, parametersTool identifier and parameter schema (kind: 'TOOL').
model, dimensionEmbedding model and dimension (kind: 'EMBEDDING').
maskPer-span redaction function.

To omit content for one span, set captureInput: false and/or captureOutput: false. To redact or transform captured telemetry for the whole client, use mask; there is no global trace-content switch. See PII Redaction.

Span kinds

span() accepts these eight kinds:

KindUse for
WORKFLOWTop-level entry point — one per trace root
AGENTA reasoning step that calls an LLM and decides what to do next
CHAINA fixed sequence of steps with no branching LLM decisions
TOOLA function the agent calls to interact with the world
RETRIEVERA vector search or document lookup
EMBEDDINGA function that produces embeddings
GUARDRAILA safety or validation check
MCP_TOOLA tool exposed over the Model Context Protocol

Passing any other kind throws. LLM, RERANKER, and VECTOR_STORE are created with trace() instead — in TypeScript these aren't in the SpanKind union, so cast the kind (e.g. kind: 'LLM' as any). For the complete catalogue, see Span Kinds.

Inline spans with trace()

Where span() wraps a reusable function, trace() runs a callback inline — for prompt-template tracking, custom span kinds, or grouping an ad-hoc block. The callback receives the active span so you can set attributes.

import { trace } from 'neatlogs';

const docs = await trace({ name: 'retrieve', kind: 'RETRIEVER' }, async (activeSpan) => {
  const results = await retriever.search(query, 5);
  activeSpan.setAttribute('neatlogs.retriever.query', query);
  return results;
});

Setting the trace output

By default a trace's displayed output is derived from the root span's captured output. Call setTraceOutput(value) inside an active trace to declare it instead — useful when the meaningful result lives in a tool call or you don't want a raw status object shown (e.g. an agent that suspends awaiting user input). It stamps neatlogs.trace.output on the trace root; it's a no-op outside a trace and never throws.

import { trace, setTraceOutput } from 'neatlogs';

await trace({ name: 'turn', sessionId }, async () => {
  const plan = await proposePlan();
  setTraceOutput(plan.title); // show the plan, not the raw status object
});

Prompt templates

PromptTemplate (system/instruction) and UserPromptTemplate (user turn) accept a string or message array with {{variable}} placeholders. Pass them to trace() so the template and compiled variables are captured on the span.

import { trace, PromptTemplate, UserPromptTemplate } from 'neatlogs';

const systemTpl = new PromptTemplate('You are a {{role}} assistant.');
const userTpl = new UserPromptTemplate('{{question}}');

const answer = await trace(
  { name: 'qa', kind: 'LLM', promptTemplate: systemTpl, userPromptTemplate: userTpl },
  async () => {
    const system = systemTpl.compile({ role: 'helpful' });
    const user = userTpl.compile({ question: 'What is TypeScript?' });
    return callLLM(system, user);
  },
);

For prompts managed in the dashboard, the module-level functions (getPrompt, createPrompt, updatePrompt, saveAsVersion, …) and the PromptClient class are available after init(). See Prompt Templates.

log()

With captureLogs: true in init(), log() records a timestamped step inside the active span. Templates use single-brace {key} placeholders; the level key sets severity.

import { log } from 'neatlogs';

log('Retrieved {count} documents in {ms}ms', { count: 5, ms: 120 });

Supported libraries

Every supported library uses an explicit mechanism. Wrappers attach to the client or framework object you pass; handlers are passed in the framework callback configuration; processors are registered with the framework; and OpenCode loads the plugin through its plugin API. These paths drive Neatlogs' private context, so a co-tenant tracer cannot capture or parent their Neatlogs spans.

LibraryExplicit mechanismImport fromVerified compatibility baseline
openaiwrapOpenAI(client)neatlogs or neatlogs/openaiopenai 6.34.x
@anthropic-ai/sdkwrapAnthropic(client)neatlogs or neatlogs/anthropic0.68.x
Azure OpenAIwrapAzureOpenAI(client)neatlogs or neatlogs/azure-openaiopenai 6.34.x
@aws-sdk/client-bedrock-runtimewrapBedrock(client)neatlogs or neatlogs/bedrockAWS SDK v3
@google/genai (Gemini / AI Studio)wrapGoogleGenAI(client) or wrapGoogleGenAIChat(chat)neatlogs or neatlogs/google-genai1.34.x
Vertex AI through @google/genaiwrapVertexAI(client) or wrapVertexAIChat(chat)neatlogs or neatlogs/vertex-ai@google/genai 1.34.x
@openrouter/agentwrapOpenRouterAgent(client) or wrapCallModel(fn)neatlogs or neatlogs/openrouter-agent0.7.x
Vercel AI SDKwrapAISDK(ai)neatlogs or neatlogs/aiai 6.x
MastrawrapMastra(entity) or wrapMastraRerank(fn)neatlogs/mastra@mastra/core 1.32.x
Claude Agent SDKwrapClaudeAgentSDK(sdk)neatlogs or neatlogs/claude-agent-sdkSDKs exposing the documented query() API
LangChain / LangGraphlangchainHandler() in config.callbacksneatlogs or neatlogs/langchain@langchain/core 0.3.x
OpenAI Agents SDKopenaiAgentsProcessor() passed to addTraceProcessor()neatlogs or neatlogs/openai-agentsSDKs exposing addTraceProcessor()
Pi AgentpiAgentHooks(agent), tracePiAgentEvents(...), or tracePiStream(...)neatlogs or neatlogs/pi-agentpi-agent-core 0.73.x and 0.83.x
OpenCodeNeatlogsOpencodePluginneatlogs/opencodeCurrent OpenCode plugin API
Browsernew Neatlogs(...)neatlogs/browserModern browsers with fetch

Each provider and framework has a runnable example in Integrations.

The package still exports strandsHooks() as a compatibility stub, but it throws: the upstream TypeScript Strands tracer uses process-global OpenTelemetry context and cannot meet Neatlogs' isolation contract. Strands is therefore supported by the Python SDK only today.

The SDK does not instrument fetch, undici, or Node HTTP clients and does not emit HTTP spans. For a raw model request, add a semantic LLM, EMBEDDING, or RERANKER span around the call rather than tracing the transport.

Explicit integrations only

Warning

TypeScript has no auto-instrumentation option. Passing the removed option raises NeatlogsConfigurationError with code UNSUPPORTED_INSTRUMENTATIONS; it never silently accepts a key. Use the exact mechanism in the table above. Python has a separate, documented auto-instrumentation surface.

OpenTelemetry isolation

Neatlogs runs on a private TracerProvider and a private active-context store — it never registers globally and never adopts or shuts down a foreign global provider. So a co-tenant tracer's active span (Datadog, Braintrust, another OpenTelemetry SDK) can't become a Neatlogs span's parent, and a Neatlogs span can't parent theirs. trace(), the auto-roots, log(), and isRootSpan() all resolve the active span from this private store, so the two systems stay cleanly separated in the same process.

If you own the provider (e.g. you already build a BasicTracerProvider), pass it as tracerProvider: the SDK adds its processors and flushes it, but never registers it globally or shuts it down — its lifecycle stays yours. When Neatlogs owns the provider it also registers beforeExit/SIGTERM/SIGINT handlers that flush + shut down on exit (so standalone scripts drain their spans); set registerShutdownHandlers: false to opt out, or true to force them on when you supply your own provider.

Propagating trace context across a boundary

Because the global OpenTelemetry propagator is deliberately left untouched, cross-process trace continuity is opt-in at the exact boundary. Call injectTraceContext(carrier) on the caller to write W3C traceparent/tracestate headers; call extractTraceContext(carrier, fn) on the callee to run a callback under that remote parent in Neatlogs' private context.

import { extractTraceContext, injectTraceContext, trace } from 'neatlogs';

const headers: Record<string, string> = {};
if (injectTraceContext(headers)) {
  await fetch(downstreamUrl, { headers });
}

// Receiving service:
await extractTraceContext(request.headers, () =>
  trace({ name: 'remote_work' }, async () => {
    // Joins the caller's trace without changing global OTel context.
  }),
);

The carrier is a TraceContextCarrier — either a plain record or a Headers-like object. extractTraceContext returns the callback's value (sync or Promise) and treats invalid/missing headers as a fail-open passthrough. W3C headers carry trace linkage only, so re-bind session/end-user identity from your request payload with identify(). See Distributed Tracing.

init() reference

OptionTypeDefaultDescription
apiKeystringNEATLOGS_API_KEY envProject API key. Export disabled if unset.
workflowNamestringfrom process.argv[1]Label all traces appear under.
tagsstring[]Tags on all spans.
userIdstringOperator/service identity (a developer, a service account, a CI job). Propagates as user.id. Distinct from the end-user — see End-User Identity.
metadataRecord<string, any>Custom metadata on all spans.
captureLogsbooleanfalseEnable log() capture.
sampleRatenumber1.0Fraction of traces to export.
maskMaskFunctionClient-side telemetry transform. It runs at the exporter boundary and drops the span on callback error, timeout, cancellation, or a nullish result.
pii / piiSpanTypesteam settingPersist or select server-side PII processing. The legacy pii values enable/disable server processing; they do not hash or redact content locally.
endpointstringhttps://ingest.neatlogs.comIngest and managed-prompt API base URL.
batchSizenumber100Max spans per export batch.
flushIntervalnumber5Seconds between batch flushes.
tracerProviderBasicTracerProviderCaller-owned private provider. Neatlogs adds processors and flushes it but never registers it globally or shuts it down.
registerShutdownHandlersbooleantrue when Neatlogs owns the provider, else falseRegister beforeExit/SIGTERM/SIGINT flush + shutdown handlers.
debugbooleanfalseVerbose logging.

Lifecycle: await flush() exports buffered logs before spans; await shutdown() drains and shuts down the default SDK generation. flushAll() drains all registered Neatlogs clients without touching foreign/global providers. See Flush and Shutdown. Environment: NEATLOGS_API_KEY supplies the project key. Override the single hosted endpoint through the endpoint option.

For PII redaction concepts see PII Redaction; to attach your app's users to traces see End-User Identity. Building in Python? See the Python SDK.

On this page

Ask Neatlogs AI

Answers from the docs

How can I help?

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