Node.js Integration
Send traces from a Node.js agent to Monte Carlo's Agent Trust with the standard OpenTelemetry Node SDK and an OpenLLMetry instrumentation.
A Node.js agent doesn't need a Monte Carlo SDK. The standard OpenTelemetry Node SDK, plus an OpenLLMetry instrumentation for your LLM client, sends the traces Monte Carlo uses to power Agent Trust. This page sets that up for an agent that calls an LLM client directly, such as an Express app, with no agent framework. For the Vercel AI SDK, see Vercel AI SDK. If your agent already starts a NodeSDK, see Already using OpenTelemetry instead of steps 1 to 3.
Before you start, you need an Agent Observability Data Platform to send traces to, and its traces URL. Deploy it in your own cloud account, or ask your Monte Carlo representative for a Monte Carlo-hosted one, which comes with an endpoint and credentials. See Before you start on the main page.
1. Install the packages
The example uses OpenAI. If your agent uses Anthropic or Bedrock, read step 5 first. With the Vercel AI SDK, read Vercel AI SDK in step 5 first.
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-trace \
@opentelemetry/exporter-trace-otlp-proto @traceloop/instrumentation-openai openai expressexpress is only for the example server in step 4. The OpenTelemetry packages require Node.js 22.15 or later.
2. Add tracing.js
tracing.js// tracing.js
const { NodeSDK } = require("@opentelemetry/sdk-node");
const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace");
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-proto");
const { context, createContextKey } = require("@opentelemetry/api");
const { OpenAIInstrumentation } = require("@traceloop/instrumentation-openai");
const ATTRIBUTES = createContextKey("montecarlo.attributes");
// Copies the attributes set with withAttributes() onto every span started inside it.
const copyAttributes = {
onStart(span, parentContext) {
const attributes = parentContext.getValue(ATTRIBUTES);
if (attributes) span.setAttributes(attributes);
},
onEnd() {},
forceFlush: async () => {},
shutdown: async () => {},
};
function withAttributes(attributes, fn) {
const merged = { ...context.active().getValue(ATTRIBUTES), ...attributes };
return context.with(context.active().setValue(ATTRIBUTES, merged), fn);
}
const openaiInstrumentation = new OpenAIInstrumentation();
const sdk = new NodeSDK({
spanProcessors: [
copyAttributes,
new BatchSpanProcessor({ exporter: new OTLPTraceExporter(), maxExportBatchSize: 128 }),
],
instrumentations: [openaiInstrumentation],
});
sdk.start();
// The instrumentation patches openai 4 to 6 on its own. This line also patches 7 or later.
openaiInstrumentation.manuallyInstrument(require("openai").OpenAI);
for (const signal of ["SIGTERM", "SIGINT"]) {
process.on(signal, () => {
setTimeout(() => process.exit(0), 5000).unref();
sdk.shutdown().catch((err) => console.error(err)).finally(() => process.exit(0));
});
}
module.exports = { withAttributes };Load it before your app, so the instrumentation is in place before openai is imported:
node --require ./tracing.js server.js- Batch size:
maxExportBatchSize: 128is set in code because this processor doesn't readOTEL_BSP_MAX_EXPORT_BATCH_SIZE. See Batch size in step 1 of the main page for why 128. - Shutdown: on
SIGTERMor Ctrl-C,sdk.shutdown()exports the spans still waiting in the batch before the process exits. If the collector can't be reached, the process exits after 5 seconds and those spans are lost. If your app handlesSIGTERMorSIGINTitself, or is a script that exits on its own, addsdktomodule.exportsintracing.js, remove each signal your app handles from the loop, andawait sdk.shutdown()before your app exits. - Serverless: a runtime that freezes the process between invocations loses the spans still waiting in the batch.
NodeSDKhas no flush method, so keep theBatchSpanProcessorin a variable, add it tomodule.exports, andawaititsforceFlush()before each invocation returns. See Serverless runtimes. - Prompts and completions: the OpenAI and Bedrock instrumentations capture them by default. To stop sending them, pass
traceContent: falseto the instrumentation, for examplenew OpenAIInstrumentation({ traceContent: false }). These instrumentations don't readTRACELOOP_TRACE_CONTENT. Without prompts and completions, your conversations reach Monte Carlo with no text. With Monte Carlo-hosted, prompts and completions are stored in infrastructure Monte Carlo runs. See Monte Carlo-hosted deployment. For Anthropic, see step 5.
ES modules
In an app that uses import, the instrumentation doesn't patch openai, so no LLM spans are created. Patch it yourself:
- If your
package.jsonhas"type": "module", name the filetracing.cjsand load it with--require ./tracing.cjs. Astracing.js, it fails withrequire is not defined. - In it, add
openaiInstrumentationtomodule.exports. - In your app, import from your tracing file, as in
import { withAttributes, openaiInstrumentation } from "./tracing.cjs";, and callopenaiInstrumentation.manuallyInstrument(OpenAI)after importingopenaiand before creating the client.
3. Set the environment variables
export OTEL_SERVICE_NAME=<your-agent-name>
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=<your-otlp-traces-url>
export OTEL_EXPORTER_OTLP_TRACES_COMPRESSION=gzip
export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=1024
export OTEL_METRICS_EXPORTER=none
export OTEL_LOGS_EXPORTER=none
export OTEL_LOG_LEVEL=error
export OTEL_NODE_RESOURCE_DETECTORS=host,os,serviceinstance,env
# Monte Carlo-hosted only:
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="x-mcd-id=<your-api-id>,x-mcd-token=<your-api-token>"- Where to set them: in the environment that starts the process, because
tracing.jsreads them when it loads. A.envfile that your app loads withdotenvis read too late. To keep them in a.envfile, load it with Node instead:node --env-file=.env --require ./tracing.js server.js. Keep that file out of version control, because it holds your API token. In a deployed environment, keep theOTEL_EXPORTER_OTLP_TRACES_HEADERSvalue in your platform's secret store: the whole value is the secret. - Agent name:
OTEL_SERVICE_NAMEis your agent's name in Monte Carlo. - Endpoint, compression and credentials: the same as in step 1 of the main page. The exporter in
tracing.jsalways sends OTLP/HTTP with protobuf, so you don't need a protocol variable. - Attribute limit: GenAI spans regularly go past OpenTelemetry's default of 128 attributes, and attributes over the limit are dropped without an error.
- Metrics and logs: the Node SDK sets up metric and log exporters unless you turn them off. Monte Carlo ingests only traces.
- Process details:
OTEL_NODE_RESOURCE_DETECTORSleaves out theprocessdetector. With it, the Node SDK sends the process's full command line, executable path and OS user with every trace, including any secret passed as an argument. - Export errors: without
OTEL_LOG_LEVEL=error, a failed export, such as a401or a400, logs nothing.
4. Tag conversations, workflows and tasks
Wrap each request in withAttributes() and pass the same gen_ai.conversation.id on every turn of a conversation. Every span started inside it gets the attributes, including the LLM spans the instrumentation creates:
// server.js
const express = require("express");
const OpenAI = require("openai");
const { trace } = require("@opentelemetry/api");
const { withAttributes } = require("./tracing");
const openai = new OpenAI();
const tracer = trace.getTracer("my-agent");
const app = express();
app.use(express.json());
app.post("/chat", async (req, res) => {
const { conversationId, message } = req.body;
if (!conversationId) return res.status(400).json({ error: "conversationId is required" });
const reply = await withAttributes(
{ "gen_ai.conversation.id": String(conversationId), "montecarlo.workflow": "support-chat" },
() =>
tracer.startActiveSpan("chat", async (span) => {
try {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: message }],
});
return completion.choices[0].message.content;
} finally {
span.end();
}
}),
);
res.json({ reply });
});
app.listen(3000);- Conversation ID: without it, traces never become conversations, so conversation evaluations, Conversation Clusters and the Reinforcement loop get nothing. Use an ID your app already keeps for the conversation, such as a chat thread ID, as a string. Don't send a placeholder when a request has no ID:
String(undefined)is"undefined", and requests that share an ID become one conversation. Start your request's spans insidewithAttributes(): a span started before it doesn't get the ID. The example reads the ID from the request body to stay short. In production, take it from your authenticated session, so a caller can't add turns to someone else's conversation. - Workflows and tasks:
montecarlo.workflowandmontecarlo.taskare optional. NestwithAttributes()calls to add a task inside a workflow. The inner call keeps the outer attributes, and every span started inside it gets the task. - Streaming: stream Chat Completions with
chat.completions.create({ stream: true })and read the stream withfor await, taking each chunk's text fromchunk.choices[0]?.delta?.content: withstream_options: { include_usage: true }, the last chunk has nochoices. Its spans carry the prompt and the response. With the instrumentation loaded,chat.completions.stream()fails, withCannot read properties of undefined (reading 'signal')onopenai6 andclient.chat.completions.create(...).finally is not a functionon 7, and the streamcreate({ stream: true })returns has nocontroller,toReadableStream()ortee(). Streamed Responses API calls,responses.stream()orresponses.create({ stream: true }), create spans without the response. - Token counts on streamed calls: streamed Chat Completions spans have no token counts, even with
stream_options: { include_usage: true }, because the instrumentation doesn't read the usage OpenAI sends at the end of a stream. Non-streamed calls carry OpenAI's own counts.new OpenAIInstrumentation({ enrichTokens: true })adds estimated counts to streamed spans, but with it, a streamed call that has a message with content parts, such as an image, fails withtext.match is not a function. Use it only if every message'scontentis a string.
5. Other LLM clients
Swap in the tracing for your client:
| Client | Package | In tracing.js |
|---|---|---|
@anthropic-ai/sdk 0.134.0 or later | None. The SDK traces its own calls. | Nothing to add |
@aws-sdk/client-bedrock-runtime | @traceloop/instrumentation-bedrock | new BedrockInstrumentation() |
Import the Bedrock instrumentation like the OpenAI one: const { BedrockInstrumentation } = require("@traceloop/instrumentation-bedrock");. If your agent uses more than one OpenLLMetry instrumentation, list them all in instrumentations.
- Anthropic only: leave
@traceloop/instrumentation-openaiandopenaiout of step 1, delete theOpenAIInstrumentationimport, theopenaiInstrumentationlines and the comment above them fromtracing.js, and passinstrumentations: []. Install@anthropic-ai/sdkif your app doesn't have it. - Bedrock only: leave
@traceloop/instrumentation-openaiandopenaiout of step 1, install@traceloop/instrumentation-bedrockand@aws-sdk/client-bedrock-runtimeinstead, delete theOpenAIInstrumentationimport, theopenaiInstrumentationlines and the comment above them fromtracing.js, and passinstrumentations: [new BedrockInstrumentation()]. - Anthropic content: set
ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content, with step 3's variables. Without it, the SDK's spans carry the model and token counts but no prompts or completions. Don't add@traceloop/instrumentation-anthropic, which crashes with@anthropic-ai/sdk1.x. - Anthropic streaming: use
client.messages.stream(). Iteratingmessages.create({ stream: true })records the prompt but not the response. - Bedrock: prompts and completions are captured for
InvokeModelCommandwith a stringbody, such asJSON.stringify(payload). With aUint8ArrayorBufferbody the prompt isn't captured, andConverseCommandcalls create spans with no prompt, completion or token counts. - Bedrock in an app that uses
import: follow ES modules in step 2 withconst bedrockInstrumentation = new BedrockInstrumentation();ininstrumentationsandmodule.exports, and pass the whole module, not the client class:import * as bedrockRuntime from "@aws-sdk/client-bedrock-runtime", thenbedrockInstrumentation.manuallyInstrument(bedrockRuntime).
Vercel AI SDK
AI SDK 7 traces its own calls in a format Monte Carlo reads, so it needs no OpenLLMetry instrumentation. On AI SDK 5 or 6, upgrade to 7: their built-in telemetry (experimental_telemetry: { isEnabled: true }) sends the model and token counts, but not prompts and completions in a format Monte Carlo reads, and neither does LegacyOpenTelemetry in AI SDK 7. Change steps 1, 2 and 4:
Step 1: install ai, your provider package such as @ai-sdk/openai, and @ai-sdk/otel, instead of @traceloop/instrumentation-openai and openai:
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-trace \
@opentelemetry/exporter-trace-otlp-proto ai@7 @ai-sdk/openai @ai-sdk/otel expressStep 2: in tracing.js, delete the OpenAIInstrumentation import, the openaiInstrumentation lines and the comment above them, pass instrumentations: [], and add after sdk.start():
const { registerTelemetry } = require("ai");
const { OpenTelemetry } = require("@ai-sdk/otel");
registerTelemetry(new OpenTelemetry());Step 4: in server.js, replace the require("openai") and new OpenAI() lines with the AI SDK imports. Keep withAttributes() and the chat span:
const { generateText } = require("ai");
const { openai } = require("@ai-sdk/openai");
// In place of openai.chat.completions.create(...):
const { text } = await generateText({ model: openai("gpt-4o-mini"), prompt: message });
return text;- Every call is traced: once the integration is registered, you don't need
experimental_telemetryon each call. - Conversation ID: wrap each request in
withAttributes(), as in step 4. The AI SDK's ownmetadataoption doesn't setgen_ai.conversation.id. - Streaming:
streamTextspans carry the prompt, the response and token counts. - Prompts and completions: captured by default. To stop sending them, pass
telemetry: { recordInputs: false, recordOutputs: false }on each call. There's no app-wide setting. - ES modules: if your
package.jsonhas"type": "module", name the filetracing.cjsand load it with--require ./tracing.cjs.
6. Confirm traces are arriving
Start your agent with node --require ./tracing.js server.js (./tracing.cjs if your package.json has "type": "module", or with --env-file=.env, as in step 3), and send two turns of one conversation:
curl -X POST localhost:3000/chat -H 'content-type: application/json' -d '{"conversationId":"test-1","message":"Hello"}'
curl -X POST localhost:3000/chat -H 'content-type: application/json' -d '{"conversationId":"test-1","message":"Tell me more"}'Then follow Confirm traces are arriving on the main page. Monte Carlo looks for new agents every 30 minutes, so the agent may not appear right away. Once it does, open Conversations from its Activity card and check for one conversation with both turns. Then see Next steps.
Already using OpenTelemetry
If your agent already starts a NodeSDK, change your tracing file instead of adding step 2's tracing.js. This section uses the OpenAI client.
Install
If you start your app with --require @opentelemetry/auto-instrumentations-node/register, you have no NodeSDK in your code. First install @opentelemetry/sdk-node at the version that package uses:
npm install @opentelemetry/sdk-node@$(node -p 'require("./node_modules/@opentelemetry/auto-instrumentations-node/package.json").dependencies["@opentelemetry/sdk-node"]')From your app's directory, install the instrumentation, and Monte Carlo's exporter at the version of your @opentelemetry/sdk-node:
SDK_NODE=$(node -p 'require("./node_modules/@opentelemetry/sdk-node/package.json").version')
npm install @opentelemetry/api @traceloop/instrumentation-openai @opentelemetry/exporter-trace-otlp-proto@$SDK_NODEIf $SDK_NODE is 0.300.0 or later (OpenTelemetry JS SDK 3.x), also install @opentelemetry/sdk-trace at the version your sdk-node uses:
npm install @opentelemetry/sdk-trace@$(node -p 'require("./node_modules/@opentelemetry/sdk-node/package.json").dependencies["@opentelemetry/sdk-trace"]')With pnpm, run pnpm add in place of npm install. In an npm workspace, where these packages are installed at the workspace root, replace ./node_modules in the commands with the path to the root's node_modules.
Change your tracing file
On SDK 3.x, with another backend:
// tracing.js
const { NodeSDK } = require("@opentelemetry/sdk-node");
const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace");
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-proto");
const { getNodeAutoInstrumentations } = require("@opentelemetry/auto-instrumentations-node");
const { context, createContextKey } = require("@opentelemetry/api");
const { OpenAIInstrumentation } = require("@traceloop/instrumentation-openai");
// Paste ATTRIBUTES, copyAttributes and withAttributes() from step 2 here.
const openaiInstrumentation = new OpenAIInstrumentation();
const sdk = new NodeSDK({
spanProcessors: [
copyAttributes,
// Your other backend:
new BatchSpanProcessor({
exporter: new OTLPTraceExporter({
url: process.env.OTHER_TRACES_URL,
headers: { "x-other-key": process.env.OTHER_KEY },
}),
}),
// Monte Carlo:
new BatchSpanProcessor({
exporter: new OTLPTraceExporter({
url: process.env.MCD_TRACES_URL,
headers: { "x-mcd-id": process.env.MCD_DEFAULT_API_ID, "x-mcd-token": process.env.MCD_DEFAULT_API_TOKEN },
compression: "gzip",
}),
maxExportBatchSize: 128,
}),
],
instrumentations: [getNodeAutoInstrumentations(), openaiInstrumentation],
});
sdk.start();
openaiInstrumentation.manuallyInstrument(require("openai").OpenAI);
for (const signal of ["SIGTERM", "SIGINT"]) {
process.on(signal, () => {
setTimeout(() => process.exit(0), 5000).unref();
sdk.shutdown().catch((err) => console.error(err)).finally(() => process.exit(0));
});
}
module.exports = { withAttributes };-
SDK 2.x: you don't need
@opentelemetry/sdk-trace. Drop the@opentelemetry/sdk-traceline, takeBatchSpanProcessorfromtracingin@opentelemetry/sdk-node, and pass the exporter first, in both processors:const { NodeSDK, tracing } = require("@opentelemetry/sdk-node"); // ... new tracing.BatchSpanProcessor( new OTLPTraceExporter({ url: process.env.MCD_TRACES_URL, headers: { "x-mcd-id": process.env.MCD_DEFAULT_API_ID, "x-mcd-token": process.env.MCD_DEFAULT_API_TOKEN }, compression: "gzip", }), { maxExportBatchSize: 128 }, ), -
Every backend in
spanProcessors: once you passspanProcessors,NodeSDKignorestraceExporterand doesn't build an exporter fromOTEL_EXPORTER_OTLP_*. Move your other backend's exporter into its ownBatchSpanProcessor, as above. Keep the exporter package you use now. With no other backend, leave its processor out. -
Keep your instrumentations: if you list instrumentations yourself, keep your list in place of
getNodeAutoInstrumentations()and drop itsrequireline. With OpenAI, addopenaiInstrumentationto it and keep themanuallyInstrument()line. -
Other clients: with Anthropic, Bedrock or the Vercel AI SDK instead of OpenAI, leave
@traceloop/instrumentation-openaiout of the install and everyopenaiInstrumentationline,manuallyInstrument()included, out of your file, and make step 5's changes for your client. Where step 5 passesinstrumentations: [...], add those to your existing list instead. -
No
NodeSDKin your code: use the file as above, and start your app with--require ./tracing.jsin place of--require @opentelemetry/auto-instrumentations-node/register. -
Shutdown: the signal loop is step 2's. It exports the spans still waiting in the batch when the process stops. If your file already shuts the SDK down, keep yours instead. See Shutdown and Serverless in step 2.
-
Credentials: give each exporter its own endpoint and credentials, from variables of your own such as
OTHER_KEYandMCD_DEFAULT_API_TOKEN.MCD_TRACES_URLis the traces endpoint from step 1 of the main page, andMCD_DEFAULT_API_IDandMCD_DEFAULT_API_TOKENare your API key's ID and token. Self-hosted, leave out thex-mcd-*headers. Take keys out ofOTEL_EXPORTER_OTLP_HEADERSandOTEL_EXPORTER_OTLP_TRACES_HEADERS, including Monte Carlo's from the main page: every OTLP exporter sends them, so a key left there reaches both backends. If your metric exporter needs that key, set it inOTEL_EXPORTER_OTLP_METRICS_HEADERSinstead. -
Environment variables: from step 3, set
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=1024, andOTEL_SERVICE_NAME,OTEL_NODE_RESOURCE_DETECTORSandOTEL_LOG_LEVEL=errorif you don't set them already. These apply to your other backend too. Leave out the rest: its endpoint and headers would send your Monte Carlo credentials to your other backend, and its metrics and logs variables would turn those off for your other backend. -
ES modules: follow item 1 of ES modules in step 2. With OpenAI, follow items 2 and 3 too. With Bedrock, follow Bedrock in an app that uses
importin step 5. -
Steps 4 and 6: apply as written, with your tracing file in place of
./tracing. The spans your HTTP and Express instrumentations start for the incoming request begin beforewithAttributes(), so they don't get the conversation ID. The LLM span and the spans started insidewithAttributes()do.
Troubleshooting
- The agent appears, but with no LLM calls, prompts or completions:
- Check that
tracing.jshas step 2'smanuallyInstrument()line. Without it,openai7 or later isn't instrumented. - Check that
tracing.jsloads before your app with--require. An instrumentation registered afteropenaiis imported doesn't patch it. - If you bundle your app, for example with esbuild, mark your LLM client package, such as
openaior@aws-sdk/client-bedrock-runtime, as external. - In an app that uses
import, see ES modules in step 2. - For streamed calls, see Streaming in step 4. With Anthropic or Bedrock, see step 5. With the Vercel AI SDK, see Vercel AI SDK.
- Check that
- A streaming call fails with
Cannot read properties of undefined (reading 'signal')or.finally is not a function:chat.completions.stream()doesn't work with the instrumentation loaded. See Streaming in step 4. - The app crashes a few seconds after its first request, with
Cannot read properties of undefined (reading 'export')orthis._exporter.export is not a function:BatchSpanProcessorfrom@opentelemetry/sdk-tracetakes{ exporter }, and the ones from@opentelemetry/sdk-trace-baseand fromtracingin SDK 2.x's@opentelemetry/sdk-nodetake the exporter first. Passing one package's form to the other fails on the first export. - Streamed calls have no token counts: see Token counts on streamed calls in step 4.
- Each turn shows up as its own conversation: pass the same
gen_ai.conversation.idon every turn. - Exports fail with
400errors: the batch is over the collector's size limit. KeepmaxExportBatchSize: 128or lower. - The agent doesn't appear: work through the checklist in Confirm traces are arriving. Also check that the variables from step 3 are set in the environment that starts the process, not loaded with
dotenv, and thatOTEL_LOG_LEVEL=erroris set so failed exports are logged.
Details and edge cases
Why the exporter is built in code. tracing.js passes spanProcessors to add copyAttributes, and that replaces the exporter NodeSDK would build from the environment. OTLPTraceExporter() still reads the OTEL_EXPORTER_OTLP_TRACES_* variables from step 3.
Why not baggage. OpenTelemetry baggage can also carry the conversation ID to every span, but it travels in a baggage header. With HTTP instrumentation on, your LLM provider and other services receive the ID, and an incoming request's baggage header can set it. withAttributes() keeps the attributes inside your process.
Updated about 16 hours ago
