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 express

express 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
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: 128 is set in code because this processor doesn't read OTEL_BSP_MAX_EXPORT_BATCH_SIZE. See Batch size in step 1 of the main page for why 128.
  • Shutdown: on SIGTERM or 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 handles SIGTERM or SIGINT itself, or is a script that exits on its own, add sdk to module.exports in tracing.js, remove each signal your app handles from the loop, and await sdk.shutdown() before your app exits.
  • Serverless: a runtime that freezes the process between invocations loses the spans still waiting in the batch. NodeSDK has no flush method, so keep the BatchSpanProcessor in a variable, add it to module.exports, and await its forceFlush() 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: false to the instrumentation, for example new OpenAIInstrumentation({ traceContent: false }). These instrumentations don't read TRACELOOP_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:

  1. If your package.json has "type": "module", name the file tracing.cjs and load it with --require ./tracing.cjs. As tracing.js, it fails with require is not defined.
  2. In it, add openaiInstrumentation to module.exports.
  3. In your app, import from your tracing file, as in import { withAttributes, openaiInstrumentation } from "./tracing.cjs";, and call openaiInstrumentation.manuallyInstrument(OpenAI) after importing openai and 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.js reads them when it loads. A .env file that your app loads with dotenv is read too late. To keep them in a .env file, 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 the OTEL_EXPORTER_OTLP_TRACES_HEADERS value in your platform's secret store: the whole value is the secret.
  • Agent name: OTEL_SERVICE_NAME is 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.js always 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_DETECTORS leaves out the process detector. 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 a 401 or a 400, 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 inside withAttributes(): 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.workflow and montecarlo.task are optional. Nest withAttributes() 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 with for await, taking each chunk's text from chunk.choices[0]?.delta?.content: with stream_options: { include_usage: true }, the last chunk has no choices. Its spans carry the prompt and the response. With the instrumentation loaded, chat.completions.stream() fails, with Cannot read properties of undefined (reading 'signal') on openai 6 and client.chat.completions.create(...).finally is not a function on 7, and the stream create({ stream: true }) returns has no controller, toReadableStream() or tee(). Streamed Responses API calls, responses.stream() or responses.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 with text.match is not a function. Use it only if every message's content is a string.

5. Other LLM clients

Swap in the tracing for your client:

ClientPackageIn tracing.js
@anthropic-ai/sdk 0.134.0 or laterNone. The SDK traces its own calls.Nothing to add
@aws-sdk/client-bedrock-runtime@traceloop/instrumentation-bedrocknew 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-openai and openai out of step 1, delete the OpenAIInstrumentation import, the openaiInstrumentation lines and the comment above them from tracing.js, and pass instrumentations: []. Install @anthropic-ai/sdk if your app doesn't have it.
  • Bedrock only: leave @traceloop/instrumentation-openai and openai out of step 1, install @traceloop/instrumentation-bedrock and @aws-sdk/client-bedrock-runtime instead, delete the OpenAIInstrumentation import, the openaiInstrumentation lines and the comment above them from tracing.js, and pass instrumentations: [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/sdk 1.x.
  • Anthropic streaming: use client.messages.stream(). Iterating messages.create({ stream: true }) records the prompt but not the response.
  • Bedrock: prompts and completions are captured for InvokeModelCommand with a string body, such as JSON.stringify(payload). With a Uint8Array or Buffer body the prompt isn't captured, and ConverseCommand calls create spans with no prompt, completion or token counts.
  • Bedrock in an app that uses import: follow ES modules in step 2 with const bedrockInstrumentation = new BedrockInstrumentation(); in instrumentations and module.exports, and pass the whole module, not the client class: import * as bedrockRuntime from "@aws-sdk/client-bedrock-runtime", then bedrockInstrumentation.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 express

Step 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_telemetry on each call.
  • Conversation ID: wrap each request in withAttributes(), as in step 4. The AI SDK's own metadata option doesn't set gen_ai.conversation.id.
  • Streaming: streamText spans 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.json has "type": "module", name the file tracing.cjs and 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_NODE

If $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-trace line, take BatchSpanProcessor from tracing in @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 pass spanProcessors, NodeSDK ignores traceExporter and doesn't build an exporter from OTEL_EXPORTER_OTLP_*. Move your other backend's exporter into its own BatchSpanProcessor, 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 its require line. With OpenAI, add openaiInstrumentation to it and keep the manuallyInstrument() line.

  • Other clients: with Anthropic, Bedrock or the Vercel AI SDK instead of OpenAI, leave @traceloop/instrumentation-openai out of the install and every openaiInstrumentation line, manuallyInstrument() included, out of your file, and make step 5's changes for your client. Where step 5 passes instrumentations: [...], add those to your existing list instead.

  • No NodeSDK in your code: use the file as above, and start your app with --require ./tracing.js in 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_KEY and MCD_DEFAULT_API_TOKEN. MCD_TRACES_URL is the traces endpoint from step 1 of the main page, and MCD_DEFAULT_API_ID and MCD_DEFAULT_API_TOKEN are your API key's ID and token. Self-hosted, leave out the x-mcd-* headers. Take keys out of OTEL_EXPORTER_OTLP_HEADERS and OTEL_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 in OTEL_EXPORTER_OTLP_METRICS_HEADERS instead.

  • Environment variables: from step 3, set OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=1024, and OTEL_SERVICE_NAME, OTEL_NODE_RESOURCE_DETECTORS and OTEL_LOG_LEVEL=error if 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 import in 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 before withAttributes(), so they don't get the conversation ID. The LLM span and the spans started inside withAttributes() do.

Troubleshooting

  • The agent appears, but with no LLM calls, prompts or completions:
    • Check that tracing.js has step 2's manuallyInstrument() line. Without it, openai 7 or later isn't instrumented.
    • Check that tracing.js loads before your app with --require. An instrumentation registered after openai is imported doesn't patch it.
    • If you bundle your app, for example with esbuild, mark your LLM client package, such as openai or @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.
  • 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') or this._exporter.export is not a function: BatchSpanProcessor from @opentelemetry/sdk-trace takes { exporter }, and the ones from @opentelemetry/sdk-trace-base and from tracing in SDK 2.x's @opentelemetry/sdk-node take 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.id on every turn.
  • Exports fail with 400 errors: the batch is over the collector's size limit. Keep maxExportBatchSize: 128 or 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 that OTEL_LOG_LEVEL=error is 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.


Did this page help you?