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 consumedInstall
@anthropic-ai/sdk is a peer dependency - use the version already in your project:
npm install @refactorlabs/recount
# @anthropic-ai/sdk already in your projectOpenAI - 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 hereInstall
openai is a peer dependency - use the version already in your project:
npm install @refactorlabs/recount
# openai already in your projectManual 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
| Field | Type | Description |
|---|---|---|
messages | LlmMessage[] | Chat messages (role + content pairs) |
prompt | string | Plain string prompt (alternative to messages) |
system | string | System prompt |
provider | string | Provider name, e.g. 'anthropic' |
model | string | Model identifier, e.g. 'gpt-4o' |
Output fields
| Field | Type | Description |
|---|---|---|
response | string | JsonObject | The model's response |
inputTokens | number | Prompt token count |
outputTokens | number | Completion token count |
stopReason | string | Why 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;
};