Recount
Node.js

LLM spans

Trace Anthropic and OpenAI calls in Node.js automatically - prompts, responses, models and token counts, streaming included.

Anthropic - wrapAnthropic

Wrap your Anthropic client once and every messages.create() call is traced automatically. Import from @refactorlabs/recount/anthropic:

import Anthropic from '@anthropic-ai/sdk';
import { wrapAnthropic } from '@refactorlabs/recount/anthropic';

const anthropic = wrapAnthropic(new Anthropic());

Pass a redact option to strip sensitive fields from all spans created by this client:

const anthropic = wrapAnthropic(new Anthropic(), { redact: ['user.ssn'] });

Then use it exactly as before:

await recount.startTrace('Assess claim', async () => {
  // llm span recorded automatically - messages, model, response, tokens
  const message = await anthropic.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 1024,
    messages: [{ role: 'user', content: prompt }]
  });

  const result = parseResponse(message.content[0].text);
});

Each span captures the provider, model, messages (and system prompt if set), response text, token counts, and stop reason.

Naming spans

By default the span name is the model identifier (e.g. claude-sonnet-4-6). Wrap the call with recount.named() to give it a meaningful name:

const message = await recount.named('Classify intent', () =>
  anthropic.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 512,
    messages: [{ role: 'user', content: prompt }]
  })
);
// → llm span named 'Classify intent'

Streaming

Streaming works transparently. Pass stream: true as normal - the span is recorded when the stream completes:

const stream = await anthropic.messages.create({
  model: 'claude-sonnet-4-6',
  max_tokens: 1024,
  messages,
  stream: true
});

let text = '';
for await (const event of stream) {
  if (
    event.type === 'content_block_delta' &&
    event.delta.type === 'text_delta'
  ) {
    text += event.delta.text;
  }
}
// span recorded here, after the stream is consumed

Install

@anthropic-ai/sdk is a peer dependency - use the version already in your project:

npm install @refactorlabs/recount
# @anthropic-ai/sdk already in your project

OpenAI - wrapOpenAI

Same pattern for OpenAI. Import from @refactorlabs/recount/openai:

import OpenAI from 'openai';
import { wrapOpenAI } from '@refactorlabs/recount/openai';

const openai = wrapOpenAI(new OpenAI());

Pass a redact option to strip sensitive fields from all spans created by this client:

const openai = wrapOpenAI(new OpenAI(), { redact: ['user.ssn'] });

Every chat.completions.create() call is traced automatically:

await recount.startTrace('Generate summary', async () => {
  // llm span recorded automatically
  const completion = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [{ role: 'user', content: prompt }]
  });
});

Naming spans

Same as Anthropic - wrap with recount.named() to override the default model-name span:

const completion = await recount.named('Summarise claim', () =>
  openai.chat.completions.create({ model: 'gpt-4o', messages })
);
// → llm span named 'Summarise claim'

Streaming

Pass stream_options: { include_usage: true } to capture token counts in the stream:

const stream = await openai.chat.completions.create({
  model: 'gpt-4o',
  messages,
  stream: true,
  stream_options: { include_usage: true }
});

let text = '';
for await (const chunk of stream) {
  text += chunk.choices[0]?.delta?.content ?? '';
}
// span recorded here

Install

openai is a peer dependency - use the version already in your project:

npm install @refactorlabs/recount
# openai already in your project

Manual spans

For other providers or when you need full control, use addSpan with type: 'llm' directly.

recount.addSpan('llm', 'Classify intent', {
  input: {
    messages: [
      { role: 'system', content: 'You are a claims assessor.' },
      { role: 'user', content: 'Is this claim fraudulent? ...' }
    ],
    provider: 'anthropic',
    model: 'claude-sonnet-4-6'
  },
  output: { response: 'No indicators of fraud detected.' },
  durationMs: 340
});

Plain prompt

If you're not using a chat-style API, pass prompt instead of messages:

recount.addSpan('llm', 'Summarise claim', {
  input: {
    prompt: 'Summarise the following claim in one sentence: ...',
    provider: 'openai',
    model: 'gpt-4o'
  },
  output: { response: 'Rear-end collision claim for $7,590 damage.' },
  durationMs: 820
});

Callback form

Use the callback form to auto-set output and timing:

const result = await recount.addSpan(
  'llm',
  'Classify intent',
  { input: { messages, provider: 'anthropic', model: 'claude-sonnet-4-6' } },
  async () => {
    const res = await fetch('https://api.yourprovider.com/generate', {
      method: 'POST',
      body: JSON.stringify({ messages })
    });
    const { text } = await res.json();
    return { response: text };
  }
);

Input fields

FieldTypeDescription
messagesLlmMessage[]Chat messages (role + content pairs)
promptstringPlain string prompt (alternative to messages)
systemstringSystem prompt
providerstringProvider name, e.g. 'anthropic'
modelstringModel identifier, e.g. 'gpt-4o'

Output fields

FieldTypeDescription
responsestring | JsonObjectThe model's response
inputTokensnumberPrompt token count
outputTokensnumberCompletion token count
stopReasonstringWhy the model stopped generating

Types

import type {
  LlmSpanInput,
  LlmSpanOutput,
  LlmMessage
} from '@refactorlabs/recount';
type LlmMessage = {
  role: 'system' | 'user' | 'assistant' | 'tool';
  content: string;
};

type LlmSpanInput = {
  prompt?: string;
  messages?: LlmMessage[];
  system?: string;
  provider?: string;
  model?: string;
};

type LlmSpanOutput = {
  response?: string | JsonObject;
  inputTokens?: number;
  outputTokens?: number;
  stopReason?: string;
};

On this page