@loongsuite/otel-util-genai creates OpenTelemetry Spans that conform to the ARMS GenAI semantic conventions. Use this SDK to generate GenAI Spans (Entry, Agent, ReAct Step, LLM, Tool, Embedding, Retrieval, Rerank, and Memory) with semantic attributes including model, messages, tokens, and tool calling. This tutorial covers instrumentation mode selection, dependency installation, initialization, Span creation for each type, context propagation, error handling, and end-to-end verification.
The manual instrumentation examples are verified on Node.js 20/22 LTS, @loongsuite/otel-util-genai@0.1.0, and OpenTelemetry JS 1.30.x. The probe examples are verified on Node.js 20, @loongsuite/cms_node_sdk@1.0.4, and openai@5.23.2. This tool requires Node.js 20 or later. Use LTS versions in production environments.
Choose an instrumentation mode
@loongsuite/cms_node_sdk probe and @loongsuite/otel-util-genai currently use different Trace/Context APIs. Do not combine them as a single Provider set in the same process, and do not initialize two export pipelines.
Scenario | Who initializes Trace and Exporter | Whether to use this util | GenAI Spans you get |
Use | ARMS Node.js probe | No | LLM Spans automatically generated by the probe |
Need complete custom hierarchy of Entry, Agent, Step, LLM, Tool, and more | Application initializes standard OpenTelemetry JS | Yes | Complete hierarchy created by the application as needed |
Use the ARMS Node.js probe for automatic collection
If you only need automatic collection of model SDK calls, use the ARMS Node.js probe. The following combination has been verified with Node.js 20, real DashScope requests, and ARMS server-side data:
npm install \
@loongsuite/cms_node_sdk@1.0.4 \
openai@5.23.2
export ARMS_LICENSE="<ARMS License>"
export CMS_SERVICE_NAME="weather-agent"
export ARMS_REGION_ID="cn-hongkong"
# No configuration required for the default workspace; set this only for non-default workspaces
# export ARMS_WORKSPACE="<workspace>"
node -r @loongsuite/cms_node_sdk/register app.jsARMS_LICENSE is a credential and must not be written into source code, images, logs, or documentation. The service name must be unique and stable within the same environment.
@loongsuite/cms_node_sdk@1.0.4 declares OpenAI automatic instrumentation support for openai >=4 <6. OpenAI 6 is not within this supported range. Even if a model request succeeds, you cannot assume LLM Spans have been generated. After upgrading OpenAI or the probe, you must redo server-side verification.
This probe's preload entry point is designed for long-running services. When a new service starts for the first time, the probe must complete its configuration handshake. CLI scripts, one-shot scripts, and extremely short Serverless processes may exit before the handshake or batch export finishes. For short tasks that need complete custom hierarchy or deterministic flush, use the manual OTLP mode in the following section.
Compatibility boundary: @loongsuite/cms_node_sdk@1.0.4 uses CMS Trace/Context and does not register the @opentelemetry/api global TracerProvider. Therefore, calling getExtendedTelemetryHandler() directly returns the default Handler from the standard OpenTelemetry API side, which cannot automatically reuse the probe and cannot guarantee parent-child relationships and export. This document does not list this combination as a supported instrumentation mode.
When the probe already creates LLM Spans automatically, do not call startLlm() again for the same request. If you also need business Spans such as Entry, Agent, Step, and Tool, choose the manual mode below and complete all instrumentation with a single standard OpenTelemetry Provider.
Use the util for full manual instrumentation
The application initializes a standard OpenTelemetry JS TracerProvider, SpanProcessor, and OTLP Exporter, and explicitly passes the provider to ExtendedTelemetryHandler.
In this mode, the application manually creates the required GenAI Spans and waits for forceFlush() and shutdown() before exiting. The rest of this document uses this mode. You will complete the following steps:
Install dependencies
Configure Resource and content collection policy
Initialize OpenTelemetry
Create GenAI Spans (Entry, Agent, Step, LLM, Tool, and others)
Handle errors and close Spans
Verify instrumentation results in the ARMS console
Prerequisites
You have activated and completed ARMS Application Monitoring access.
You use Node.js 20 or 22 LTS.
You have obtained the OTLP HTTP endpoint and authentication header for the current application and region from the ARMS console.
You have confirmed that the
service.nameof the application is unique and stable within the same environment.Instrumentation fields comply with the LLM Trace field definition description.
Do not write API keys, OTLP authentication headers, complete user input, or other sensitive data into source code or logs.
Install dependencies
To ensure reproducibility, this document pins verified release versions and does not use bare package names. The verification baseline for this document is @loongsuite/otel-util-genai@0.1.0. After upgrading to subsequent versions, you must re-verify by following the verification section. All pre-release versions are not treated as production instrumentation baselines in this document.
npm install @loongsuite/otel-util-genai@0.1.0Manual instrumentation also requires the verified OpenTelemetry 1.30.x SDK and OTLP HTTP Exporter:
npm install \
@opentelemetry/api@1.9.1 \
@opentelemetry/sdk-trace-node@1.30.1 \
@opentelemetry/sdk-trace-base@1.30.1 \
@opentelemetry/resources@1.30.1 \
@opentelemetry/exporter-trace-otlp-http@0.57.2@loongsuite/otel-util-genai currently has its peer dependency within the OpenTelemetry JS 1.x version line. Do not replace the examples in this document with OpenTelemetry SDK 2.x without complete compatibility verification.
The fixed 1.30.1 combination in this document is the compatibility baseline for @loongsuite/otel-util-genai@0.1.0. For known security issues in the 1.x line and mitigation guidance, see OpenTelemetry 1.x security notices later in this document.
Configure Resource and content collection policy
Resource
Set at least a stable service name and mark the GenAI application and instrumentation source for ARMS:
export OTEL_SERVICE_NAME="weather-agent"
export OTEL_RESOURCE_ATTRIBUTES="acs.arms.service.feature=genai_app,gen_ai.instrumentation.sdk.name=loongsuite-genai-utils"acs.arms.service.feature and gen_ai.instrumentation.sdk.name are Resource Attributes, not regular Span Attributes. If OTEL_RESOURCE_ATTRIBUTES is already set, append with a comma. Do not overwrite existing attributes such as service.namespace and deployment.environment.name.
Message content collection
The environment variables in this section only control message recording by the util. When the application uses the util directly without setting environment variables, messages are processed in NO_CONTENT mode, which does not collect complete message content. Explicitly set the desired mode before starting the process:
export OTEL_SEMCONV_STABILITY_OPT_IN="gen_ai_latest_experimental"
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT="SPAN_ONLY"Available values:
Value | Span attributes | Event | Suggested usage |
| Not recorded | Not recorded | Default production configuration |
| Recorded | Not recorded | Debugging or sanitized business data |
| Not recorded | Recorded | Use when OTel Logs is configured |
| Recorded | Recorded | Use only after confirming duplicate storage and compliance risk |
Message content may contain personal information, business secrets, prompts, and tool parameters. Before enabling this in production environments, complete sanitization, permission, and retention period assessments.
@loongsuite/cms_node_sdk@1.0.4 OpenAI automatic instrumentation uses the probe's own content collection configuration. You cannot use the util environment variables above to infer its behavior. This version records OpenAI request and response content by default. If the business does not allow content to enter traces and you cannot confirm that content collection is turned off on the probe side, switch to manual mode and keep NO_CONTENT.
Initialize OpenTelemetry for manual instrumentation
The following initialization file must be loaded before business modules:
// telemetry.mjs
import { trace } from "@opentelemetry/api";
import { OTLPTraceExporter } from
"@opentelemetry/exporter-trace-otlp-http";
import {
Resource,
detectResourcesSync,
envDetectorSync,
} from "@opentelemetry/resources";
import {
BatchSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import {
NodeTracerProvider,
} from "@opentelemetry/sdk-trace-node";
import {
ExtendedTelemetryHandler,
} from "@loongsuite/otel-util-genai";
const detected = detectResourcesSync({
detectors: [envDetectorSync],
});
const resource = Resource.default()
.merge(detected)
.merge(new Resource({
"service.name":
process.env.OTEL_SERVICE_NAME ?? "weather-agent",
"acs.arms.service.feature": "genai_app",
"gen_ai.instrumentation.sdk.name":
"loongsuite-genai-utils",
}));
const provider = new NodeTracerProvider({ resource });
provider.addSpanProcessor(
new BatchSpanProcessor(new OTLPTraceExporter()),
);
provider.register();
export const handler = new ExtendedTelemetryHandler({
tracerProvider: provider,
});
export const tracer = trace.getTracer("weather-agent", "1.0.0");
export async function shutdownTelemetry() {
await provider.forceFlush();
await provider.shutdown();
}Copy the connection parameters from the ARMS console. Use the variable names and URL formats exactly as shown in the console example. Do not construct the region, path, or authentication parameters yourself.
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="<traces endpoint provided by the console>"
export OTEL_EXPORTER_OTLP_HEADERS="<authentication header provided by the console>"If the console provides a generic OTEL_EXPORTER_OTLP_ENDPOINT, use that variable as shown in the console example.
The application entry point must load telemetry.mjs first and flush data before the process exits:
import {
handler,
shutdownTelemetry,
} from "./telemetry.mjs";
import { runRequest } from "./agent.mjs";
try {
await runRequest({ handler });
} finally {
await shutdownTelemetry();
}ConsoleSpanExporter can only be used for local observation. It cannot send data to ARMS.
Span types and naming
The Span name and gen_ai.operation.name are two different fields and must not be confused.
Type | Span name |
| Factory function |
Entry |
|
|
|
Agent |
|
|
|
ReAct Step |
|
|
|
LLM |
|
|
|
Tool |
|
|
|
Embedding |
|
|
|
Retrieval |
|
|
|
Rerank |
|
|
|
Memory |
|
|
|
A typical Agent request trace should be:
ENTRY enter_ai_application_system
AGENT invoke_agent WeatherAgent
STEP react step
LLM chat qwen-plus
TOOL execute_tool get_weather
STEP react step
LLM chat qwen-plusWhether Tool belongs to the same Step as LLM depends on the actual execution model of the application. Do not fabricate parent-child relationships to match a specific screenshot.
Node.js context propagation rules
Correct context propagation is the most important factor for Node.js instrumentation.
Context is not activated automatically
startXxx() saves the OTel Context corresponding to the new Span into invocation.contextToken, but does not automatically set it as context.active().
When creating manual child Spans, you must pass the parent invocation's contextToken as the second argument:
handler.startInvokeAgent(agentInv, entryInv.contextToken);
handler.startReactStep(stepInv, agentInv.contextToken);
handler.startLlm(llmInv, stepInv.contextToken);
handler.startExecuteTool(toolInv, stepInv.contextToken);When calling a model SDK, HTTP client, or database that may have automatic instrumentation, you must also use context.with() to activate the corresponding context:
import { context } from "@opentelemetry/api";
const response = await context.with(
llmInv.contextToken,
() => modelClient.chat.completions.create(request),
);Otherwise, automatically generated SDK/HTTP Spans may become sibling nodes of the LLM Span, or even generate a separate trace.
Public attributes are inherited through Baggage
sessionId, userId on Entry and agentName on Agent are written to OTel Baggage. Subsequent GenAI Spans created with their contextToken as the parent context can inherit:
gen_ai.session.idgen_ai.user.idgen_ai.agent.name
Inheritance depends on correct parent Context. When the parent-child relationship is broken, these public attributes may also be missing.
Why this document uses explicit start/stop
The Handler also provides callback-style interfaces such as entry(), invokeAgent(), and llm(), but these callback-style interfaces do not automatically execute callbacks with the new invocation's Context.
Whenever the callback calls auto-instrumented SDKs or needs to create multi-level GenAI Spans, use the explicit startXxx(), context.with(), stopXxx()/failXxx() pattern so that context boundaries are clear.
Create Entry, Agent, and ReAct Step
import {
createEntryInvocation,
createInvokeAgentInvocation,
createReactStepInvocation,
} from "@loongsuite/otel-util-genai";
const entryInv = createEntryInvocation({
sessionId,
userId,
agentName: "WeatherAgent",
inputMessages: [{
role: "user",
parts: [{ type: "text", content: userMessage }],
}],
});
handler.startEntry(entryInv);
const agentInv = createInvokeAgentInvocation("dashscope", {
agentName: "WeatherAgent",
agentDescription: "Query the weather tool first, then answer the user's question.",
requestModel: "qwen-plus",
});
handler.startInvokeAgent(agentInv, entryInv.contextToken);
const stepInv = createReactStepInvocation({ round: 1 });
handler.startReactStep(stepInv, agentInv.contextToken);Stop Spans from the inside out when complete:
stepInv.finishReason = "stop";
handler.stopReactStep(stepInv);
agentInv.inputTokens = totalInputTokens;
agentInv.outputTokens = totalOutputTokens;
agentInv.outputMessages = finalOutputMessages;
handler.stopInvokeAgent(agentInv);
entryInv.outputMessages = finalOutputMessages;
handler.stopEntry(entryInv);If the upstream returns a reliable total_tokens, you can explicitly set totalTokens. When not set, the tool calculates the total from input and output tokens.
Create an LLM Span
Model SDK collected by the same OpenTelemetry Provider
This section applies only when automatic instrumentation and the util explicitly share the same @opentelemetry/api TracerProvider. It does not apply to the @loongsuite/cms_node_sdk@1.0.4 scenario described in the probe section.
Do not create a manual LLM Span. Only execute the model call within the Step's Context:
const response = await context.with(
stepInv.contextToken,
() => modelClient.chat.completions.create(request),
);Before release, confirm that a single model request produces only one LLM Span, and confirm that the automatic LLM Span's parentSpanId points to the corresponding Step.
Create an LLM Span manually
import {
createLLMInvocation,
} from "@loongsuite/otel-util-genai";
const llmInv = createLLMInvocation({
provider: "dashscope",
operationName: "chat",
requestModel: "qwen-plus",
inputMessages: toGenAIInputMessages(messages),
toolDefinitions: toGenAIToolDefinitions(tools),
});
handler.startLlm(llmInv, stepInv.contextToken);
try {
const response = await context.with(
llmInv.contextToken,
() => modelClient.chat.completions.create({
model: "qwen-plus",
messages,
tools,
}),
);
const choice = response.choices?.[0];
if (!choice?.message) {
throw new Error("The model response has no first choice");
}
llmInv.responseId = response.id ?? null;
llmInv.responseModelName = response.model ?? "qwen-plus";
llmInv.finishReasons = [
choice.finish_reason ?? "stop",
];
llmInv.outputMessages = [
toGenAIOutputMessage(
choice.message,
choice.finish_reason,
),
];
if (response.usage) {
llmInv.inputTokens =
response.usage.prompt_tokens ?? null;
llmInv.outputTokens =
response.usage.completion_tokens ?? null;
llmInv.totalTokens =
response.usage.total_tokens ?? null;
}
handler.stopLlm(llmInv);
} catch (error) {
handler.failLlm(
llmInv,
toSafeGenAIError(
error,
"LLMError",
"LLM request failed",
),
);
throw error;
}Do not stop the LLM Span before sending the request. Otherwise, latency, automatic child Spans, and error status will be inaccurate.
Convert OpenAI-compatible messages
You cannot convert only { role, content }. The second-round model input for an Agent also includes assistant tool_calls and tool role tool results. Losing these fields prevents ARMS from reconstructing a complete tool calling cycle.
export function toGenAIMessageFinishReason(reason) {
return reason === "tool_calls" ? "tool_call" : reason || "stop";
}
function textPart(content) {
return { type: "text", content };
}
function toolCallPart(toolCall) {
return {
type: "tool_call",
id: toolCall.id ?? null,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
};
}
export function toGenAIInputMessages(messages) {
return messages.map((message) => {
if (message.role === "tool") {
return {
role: "tool",
parts: [{
type: "tool_call_response",
id: message.tool_call_id ?? null,
response: message.content ?? "",
}],
};
}
if (
message.content != null &&
typeof message.content !== "string"
) {
throw new TypeError(
"This example only accepts string message content.",
);
}
const parts = [];
if (message.content) {
parts.push(textPart(message.content));
}
for (const toolCall of message.tool_calls ?? []) {
parts.push(toolCallPart(toolCall));
}
return { role: message.role, parts };
});
}
export function toGenAIOutputMessage(message, finishReason) {
const parts = [];
if (message.content) {
parts.push(textPart(message.content));
}
for (const toolCall of message.tool_calls ?? []) {
parts.push(toolCallPart(toolCall));
}
return {
role: "assistant",
parts,
finishReason: toGenAIMessageFinishReason(finishReason),
};
}
export function toGenAIToolDefinitions(tools) {
return tools.map((tool) => ({
type: "function",
name: tool.function.name,
description: tool.function.description ?? null,
parameters: tool.function.parameters ?? {},
}));
}Span attributes and message JSON use different finish reason values. Do not apply the same conversion to both:
Model returns choice.finish_reason = "tool_calls"
gen_ai.response.finish_reasons = ["tool_calls"]
output message.finish_reason = "tool_call"gen_ai.response.finish_reasons should retain the original value returned by the model provider. Only convert the plural tool_calls to the singular tool_call when writing to the output message Schema.
toolCall.function.arguments is usually a JSON string. Unless the business has already parsed it successfully and wants to record it as an object, retain the model's original value to avoid inconsistency between instrumentation data and actual requests.
Create a Tool Span
import {
createExecuteToolInvocation,
} from "@loongsuite/otel-util-genai";
const toolInv = createExecuteToolInvocation(
toolCall.function.name,
{
toolCallId: toolCall.id ?? null,
toolDescription: "Query the weather for a specified city",
toolType: "function",
toolCallArguments: toolCall.function.arguments,
},
);
handler.startExecuteTool(toolInv, stepInv.contextToken);
try {
const result = await context.with(
toolInv.contextToken,
() => dispatchTool(
toolCall.function.name,
toolCall.function.arguments,
),
);
toolInv.toolCallResult = result;
handler.stopExecuteTool(toolInv);
} catch (error) {
handler.failExecuteTool(
toolInv,
toSafeGenAIError(
error,
"ToolError",
"Tool execution failed",
),
);
throw error;
}After tool calling completes, also append the result to the next-round input in OpenAI-compatible format:
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});toolCallId must be consistent across the assistant tool call, Tool Span, and tool response.
toolCallArguments and toolCallResult are written directly to the Tool Span and are not controlled by the message content collection switch. Sanitize these two fields before setting them. Content that must not be reported should be omitted, not left for NO_CONTENT to filter automatically.
Handle errors and close Spans layer by layer
Every invocation that has been started and is still recording must call the corresponding stopXxx() or failXxx() exactly once.
Raw error messages may contain model response bodies, request parameters, file paths, or credentials. failXxx() writes the provided message to Span Status, so you must not pass error.message or String(error) directly. Use a fixed safe message and retain only constrained error types:
const SAFE_ERROR_TYPE_PATTERN =
/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
function toSafeGenAIError(error, fallbackType, safeMessage) {
const candidateType =
error instanceof Error
? error.constructor?.name
: null;
const type =
typeof candidateType === "string" &&
SAFE_ERROR_TYPE_PATTERN.test(candidateType)
? candidateType
: fallbackType;
return { type, message: safeMessage };
}When a nested call fails, mark errors from the inside out:
failLlm / failExecuteTool
-> failReactStep
-> failInvokeAgent
-> failEntryIn the outer catch block, check first:
if (stepInv.span?.isRecording()) {
handler.failReactStep(
stepInv,
toSafeGenAIError(
error,
"StepError",
"Agent step failed",
),
);
}This avoids ending an already-finished Span a second time. failXxx() records the error status and error.type. After calling it, you must still rethrow the original error. Do not swallow business errors. If the application needs to log the error, also use a fixed safe message or audited sanitized fields. Do not output raw errors directly.
Create regular business Spans
Use native OTel Spans for internal operations that do not belong to GenAI semantics. For example, record argument validation within a Tool:
import {
context,
SpanStatusCode,
trace,
} from "@opentelemetry/api";
const validationSpan = tracer.startSpan(
"validate_weather_arguments",
{ attributes: { "app.validation.type": "json-schema" } },
toolInv.contextToken,
);
const validationContext = trace.setSpan(
toolInv.contextToken,
validationSpan,
);
try {
await context.with(
validationContext,
() => validateArguments(argumentsJson),
);
} catch (error) {
const safeError = toSafeGenAIError(
error,
"ValidationError",
"Argument validation failed",
);
validationSpan.recordException({
name: safeError.type,
message: safeError.message,
});
validationSpan.setAttribute(
"error.type",
safeError.type,
);
validationSpan.setStatus({
code: SpanStatusCode.ERROR,
message: safeError.message,
});
throw error;
} finally {
validationSpan.end();
}Custom attributes should use a business namespace such as app.*. Do not overwrite fields maintained by the tool, such as gen_ai.*, server.*, and error.type, with custom values.
Embedding and Retrieval operations
Other operations follow the same lifecycle: create invocation, pass in parent Context, fill in results after the actual operation completes, and end normally or with an error.
const embeddingInv = createEmbeddingInvocation(
"text-embedding-v3",
{ provider: "dashscope" },
);
handler.startEmbedding(embeddingInv, stepInv.contextToken);
// Call the embedding API
embeddingInv.inputTokens = usage.prompt_tokens;
embeddingInv.dimensionCount = vectors[0].length;
handler.stopEmbedding(embeddingInv);
const retrievalInv = createRetrievalInvocation({
dataSourceId: "product-docs",
query,
topK: 5,
});
handler.startRetrieval(retrievalInv, stepInv.contextToken);
retrievalInv.documents = documents.map((document) => ({
id: document.id,
score: document.score,
content: document.content,
metadata: document.metadata,
}));
handler.stopRetrieval(retrievalInv);Multimodal input instrumentation
Image URL input
The request object sent by the application to the model and the message object written to GenAI Spans use two different schemas. Construct them separately. The following example uses the image URL actually received by the model, so it maps to a Uri Part in the GenAI messages:
const prompt = "Describe this image in one sentence.";
const imageUrl = "https://example.com/image.jpg";
const inputMessages = [{
role: "user",
parts: [
{ type: "text", content: prompt },
{
type: "uri",
mimeType: "image/jpeg",
modality: "image",
uri: imageUrl,
},
],
}];
const llmInv = createLLMInvocation({
provider: "dashscope",
operationName: "chat",
requestModel: "qwen3-vl-plus",
inputMessages,
outputType: "text",
});
handler.startLlm(llmInv, entryInv.contextToken);
try {
const response = await context.with(
llmInv.contextToken,
() => modelClient.chat.completions.create({
model: "qwen3-vl-plus",
messages: [{
role: "user",
content: [
{ type: "text", text: prompt },
{
type: "image_url",
image_url: { url: imageUrl },
},
],
}],
}),
);
const choice = response.choices?.[0];
if (!choice?.message) {
throw new Error("The model response has no first choice");
}
llmInv.responseId = response.id ?? null;
llmInv.responseModelName =
response.model ?? "qwen3-vl-plus";
llmInv.finishReasons = [
choice.finish_reason ?? "stop",
];
llmInv.inputTokens =
response.usage?.prompt_tokens ?? null;
llmInv.outputTokens =
response.usage?.completion_tokens ?? null;
llmInv.totalTokens =
response.usage?.total_tokens ?? null;
llmInv.outputMessages = [{
role: "assistant",
parts: [{
type: "text",
content: choice.message.content ?? "",
}],
finishReason: choice.finish_reason ?? "stop",
}];
handler.stopLlm(llmInv);
} catch (error) {
handler.failLlm(
llmInv,
toSafeGenAIError(
error,
"LLMError",
"Multimodal LLM request failed",
),
);
throw error;
}The TypeScript public API uses mimeType. In SPAN_ONLY or SPAN_AND_EVENT mode, 0.1.0 automatically generates:
gen_ai.input.messages
gen_ai.input.multimodal_metadataBoth attributes use the Schema field mime_type for URI data:
[
{
"type": "uri",
"mime_type": "image/jpeg",
"uri": "https://example.com/image.jpg",
"modality": "image"
}
]Multi-modal metadata only summarizes Uri Parts in the final messages. It does not include Text, BLOB, Base64Blob, or File. The tool only records telemetry data and is not responsible for uploading media, calling models, or converting provider file objects into URIs.
File Part support boundaries
File Part in TypeScript uses:
{
type: "file",
mimeType: "application/pdf",
modality: "document",
fileId: "file-123",
}When written to message JSON, fileId is converted to file_id and mimeType is converted to mime_type. However, different model providers have significantly different interfaces for file upload and file ID referencing. This document does not treat File Part as an example that has completed real model end-to-end verification. Only map File Part when the application actually uses a file ID returned by the provider to send a model request. Do not construct fake file IDs just to demonstrate the field.
Event Log field conventions
Use camelCase when calling TypeScript factory functions directly:
{ type: "uri", mimeType: "image/png", modality: "image", uri }
{ type: "file", mimeType: "application/pdf", modality: "document", fileId }Event Log is the production JSON Schema. Input records must use snake_case:
{
"type": "uri",
"mime_type": "image/png",
"modality": "image",
"uri": "https://example.com/input.png"
}{
"type": "file",
"mime_type": "application/pdf",
"modality": "document",
"file_id": "file-123"
}Do not mix TypeScript object fields with Event Log Schema. convertEventLogToTrace() generates the following based on the final input/output messages:
gen_ai.input.multimodal_metadata
gen_ai.output.multimodal_metadataBoth metadata fields only summarize URI Parts in their respective messages. The companion Event Log Demo uses an image/png input URI and an image/webp output URI to verify bidirectional serialization. It verifies Event Log conversion and OTLP reporting, and does not represent that a specific model actually generated an image.
Local testing and end-to-end verification
Offline unit test
Tests should not only assert that Spans are generated, but also check:
All Spans share the same traceId.
The Agent's parentSpanId points to Entry.
The Step's parentSpanId points to Agent.
LLM and Tool parentSpanId values point to the corresponding Step.
Automatically generated SDK/HTTP Spans are under the manual LLM or Tool Span.
gen_ai.session.id,gen_ai.user.id, andgen_ai.agent.nameare inherited.LLM and Agent token values are correct.
Both assistant tool call and tool response are retained.
Multi-modal fields use
mime_type/file_id, with no residual camelCase.Multi-modal metadata only contains URI Parts.
All Spans on the error chain are marked as Error.
After downloading the companion Demo, run:
cd nodejs-genai-util-demo
npm ci
npm test
npm run demoThe Demo's package.json should pin the npm versions verified in this document. Customer-facing Demos must not depend on file:../... local source code paths.
Real model verification
export DASHSCOPE_API_KEY="<your-api-key>"
npm run demo:dashscopeThis step uses an in-memory Exporter to separately confirm that the model, tool calling, and message conversion are correct. Check that the model actually completed tool calling, and record the traceId from the program output. Do not print API keys in logs.
Tool calling to OTLP combined verification
export OTEL_SERVICE_NAME="weather-agent-validation"
export OTEL_RESOURCE_ATTRIBUTES="service.name=weather-agent-validation"
export OTEL_EXPORTER_OTLP_ENDPOINT="<ARMS endpoint>"
export OTEL_EXPORTER_OTLP_HEADERS="<ARMS auth header>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export GENAI_DEMO_ALLOW_CONTENT_EXPORT="true"
npm run demo:e2eGENAI_DEMO_ALLOW_CONTENT_EXPORT=true is the companion Demo's safety confirmation switch, indicating that the operator confirms the complete input and output in the example can be sent to the configured OTLP backend. It does not automatically sanitize data. Use only public, fictitious, or already sanitized data.
Only after both forceFlush() and shutdown() succeed does the Demo output:
export completed traceId=<trace-id>A successful export request from the exporter only proves that the client completed the export request. It cannot replace ARMS console verification.
If the console provides OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, use that variable as shown in the console example. Do not rewrite the URL yourself.
Image URL to OTLP combined verification
export DASHSCOPE_API_KEY="<your-api-key>"
export OTEL_SERVICE_NAME="multimodal-agent-validation"
export OTEL_RESOURCE_ATTRIBUTES="service.name=multimodal-agent-validation"
export OTEL_EXPORTER_OTLP_ENDPOINT="<ARMS endpoint>"
export OTEL_EXPORTER_OTLP_HEADERS="<ARMS auth header>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export GENAI_DEMO_ALLOW_CONTENT_EXPORT="true"
npm run demo:multimodal-e2eThe image must be a public HTTPS URL without sensitive content and must not contain usernames, passwords, query parameters, fragments, or temporary signing parameters. The complete URI is written to both gen_ai.input.messages and gen_ai.input.multimodal_metadata.
The program should output the actual model response, util.version, model name, response ID, finish reason, tokens, and traceId. The model response must reflect the image content. Receiving only HTTP 200 does not prove that the model processed the image.
Event Log to OTLP combined verification
This use case does not call a model. It only verifies Event Log conversion, input/output multi-modal fields, and OTLP reporting:
export OTEL_SERVICE_NAME="event-log-validation"
export OTEL_RESOURCE_ATTRIBUTES="service.name=event-log-validation"
export OTEL_EXPORTER_OTLP_ENDPOINT="<ARMS endpoint>"
export OTEL_EXPORTER_OTLP_HEADERS="<ARMS auth header>"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export GENAI_DEMO_ALLOW_CONTENT_EXPORT="true"
npm run demo:event-log-e2eThe program should output span.count=4, input.mime_type=image/png, output.mime_type=image/webp, and traceId. The server should receive ENTRY -> AGENT -> STEP -> LLM, with both gen_ai.input.multimodal_metadata and gen_ai.output.multimodal_metadata present.
ARMS Node.js probe automatic collection verification
This use case is installed separately from the manual util Demo to avoid mixing in a second Provider:
cd nodejs-genai-util-demo/arms-probe-demo
npm ci
export DASHSCOPE_API_KEY="<your-api-key>"
export ARMS_LICENSE="<ARMS License>"
export CMS_SERVICE_NAME="probe-openai-validation"
export ARMS_REGION_ID="cn-hongkong"
# No configuration required for the default workspace
# export ARMS_WORKSPACE="<workspace>"
node -r @loongsuite/cms_node_sdk/register app.jsThe companion verification program pins @loongsuite/cms_node_sdk@1.0.4 and openai@5.23.2. By default, it waits 65 seconds for the first configuration handshake of a new service before making real DashScope calls. For services that are already registered, set PROBE_WARMUP_MS=0 to shorten verification.
The server should at minimum show the parent-child relationship of HTTP SERVER -> openai.chat LLM -> model HTTP CLIENT. The openai.chat Span should include:
gen_ai.span.kind=LLMandgen_ai.operation.name=chatRequest/response model, response ID, and finish reason
Input/output/total tokens
Input and output messages (only when the business allows content recording)
otel.scope.name=openai,otel.scope.version=1.0.4Resource with
telemetry.sdk.name=cms_node_sdkand correctservice.name
If logs show BatchSpanProcessor: span export failed, HTTP 404/403, or the service cannot be found in ARMS, do not assume instrumentation is successful just because the model returned 200. Confirm that License, region, workspace, and service configuration are consistent. Allow first-time configuration handshake time for new services. For short tasks, switch to manual OTLP mode.
Verify instrumentation results
Use the output traceId to verify each item in the LLM Application Monitoring page under Trace Explorer in the ARMS console:
The trace can be retrieved under the correct region, correct application, and correct
service.name.The call chain tree matches the business execution order.
A single model request has only one LLM Span.
Every GenAI Span has the correct
gen_ai.span.kindandgen_ai.operation.name.LLM includes
gen_ai.request.model, input/output/total tokens.Agent tokens equal the aggregate of its contained model calls.
Tool includes name, call ID, arguments, and result.
With content collection enabled, the second-round LLM input includes both tool call and tool response.
Entry, Agent, Step, LLM, and Tool all have session/user/agent public attributes.
All open Spans in the failure case are marked as Error.
The image verification
gen_ai.input.messagescontainstype=uri,mime_type,modality, and a real URL.gen_ai.input.multimodal_metadatais a JSON array and only summarizes URI Parts.Event Log verification
gen_ai.output.messagesandgen_ai.output.multimodal_metadatacontain output URIs and usemime_type.No residual
mimeType/fileIdin message JSON and metadata.otel.scope.versionmatches the actually installed npm version.Resource contains
service.name,acs.arms.service.feature=genai_app, andgen_ai.instrumentation.sdk.name=loongsuite-genai-utils.
The website example maintainer should also verify separately in clean directories on both Node.js 20 and 22 with the same npm versions. Examples must not be marked as verified when server-side querying is not completed, local source code is still used, or versions are inconsistent.
OpenTelemetry 1.x security notices
The fixed 1.30.1 combination in this document is the compatibility acceptance baseline for @loongsuite/otel-util-genai@0.1.0 and does not represent that it includes all subsequent security fixes from OpenTelemetry 2.x. Dependency scanning will report the following two issues:
@opentelemetry/core <2.8.0does not limit the total size and entry count of inbound W3C Baggage parsing within the propagator (CVE-2026-54285). The default 16 KiB HTTP header limit in Node.js reduces the general HTTP attack surface. Gateways should still limit header size. When using custom message transport, raising the header limit, or using a customTextMapGetter, limit the size and entry count of untrusted baggage before calling the propagator.@opentelemetry/propagator-jaeger <2.9.0may cause process exit when parsing malformeduber-trace-idoruberctx-*headers (CVE-2026-59892). This package is installed as a transitive dependency of@opentelemetry/sdk-trace-node, but this document does not enable the Jaeger Propagator. The default W3C TraceContext/Baggage configuration is not affected by this issue. Do not setOTEL_PROPAGATORS=jaegerwith this 1.x combination. If legacy systems must receive Jaeger headers, filter or validate these headers at the gateway, and plan an upgrade with complete compatibility testing.
Do not upgrade individual dependencies to 2.x solely to suppress scan warnings, as this may cause API/SDK version mismatch. Treat the upgrade as an independent change, and re-verify unit tests, OTLP export, and ARMS server-side traces. For security advisories, see GHSA-8988-4f7v-96qf and GHSA-45rx-2jwx-cxfr.
FAQ
No data in ARMS at all
Verify that the endpoint, region, and authentication header are from the same application.
Verify
service.name.Confirm you are using an OTLP Exporter, not
ConsoleSpanExporter.Confirm that
forceFlush()andshutdown()are called before the process exits.Check the Exporter error log and network egress.
When using the ARMS Node.js probe, also check ARMS_LICENSE, ARMS_REGION_ID, ARMS_WORKSPACE, and CMS_SERVICE_NAME. Allow configuration handshake time for the first startup of a new service. If the model call succeeds but exporter 404/403 appears, it is still a reporting failure.
No LLM Span under ARMS probe
Confirm that Node.js startup arguments include
-r @loongsuite/cms_node_sdk/registerand that the probe loads beforeopenai.@loongsuite/cms_node_sdk@1.0.4only declares support foropenai >=4 <6. OpenAI 6 is not within the verified range.Check whether the startup log lists
openaiinstrumentation.Confirm with ARMS server-side data, not just by looking at the model response.
util does not export Spans
@loongsuite/cms_node_sdk@1.0.4 does not register the standard @opentelemetry/api Provider and cannot be directly reused by the default Handler. Do not mix the two approaches. When you need the util, initialize a standard OpenTelemetry Provider and OTLP Exporter according to the initialization section.
Two LLM Spans for the same model call
This usually means the model SDK is already collected by automatic instrumentation while the business also calls startLlm(). Keep only one type of LLM instrumentation within the same Provider. @loongsuite/cms_node_sdk@1.0.4 and the util should be fully separated according to the instrumentation mode section.
Spans are in the same trace, but the hierarchy is wrong
Check whether manual child Spans explicitly pass the parent
contextToken.Check whether auto-instrumented operations execute within
context.with().Check whether the code crosses custom async boundaries that do not correctly propagate the AsyncLocalStorage Context.
Child Spans are missing session, user, or agent
Check whether Entry/Agent has the corresponding fields set, and confirm that child Spans use the parent invocation's contextToken.
Input and output messages are not visible
Confirm that environment variables are set before the Node.js process starts:
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLYThen check whether the message conversion result conforms to the { role, parts } structure.
Image messages present but no multi-modal metadata
Confirm the installed version is the verified release
0.1.0from this document. When using subsequent versions, re-run the complete verification.Confirm the content collection mode is
SPAN_ONLYorSPAN_AND_EVENT.Metadata only summarizes
type: "uri"Parts, not BLOB, Base64Blob, or File.When calling the TypeScript API directly, check
mimeType,modality, anduri.For Event Log input, check the Schema fields
mime_type,modality, anduri.Check whether
invocation.attributesexplicitly overwrites automatically generated attributes.
File Part does not appear in multi-modal metadata
This is expected behavior. File Part is retained in message JSON and serializes fileId as file_id, but URI metadata only summarizes Uri Parts. Only record File Part when the provider's actual request uses a file ID.
Peer dependency errors during installation
Check whether the project mixes OpenTelemetry SDK 1.x and 2.x. The verified combination in this document is API 1.9.1, trace/resources 1.30.1, and OTLP exporter 0.57.2.
Intermittent Span loss in short tasks
In manual OTLP mode, do not rely on natural process exit. CLI scripts, Serverless handlers, and test programs must all explicitly wait for forceFlush()/shutdown().
@loongsuite/cms_node_sdk/register signal handling executes shutdown on SIGINT/SIGTERM, but new services still need the first configuration handshake. For extremely short tasks, prefer the manual OTLP mode where you can explicitly control the lifecycle.
References
Companion Demo
The companion Demo includes:
Offline model and in-memory Exporter
Real DashScope OpenAI-compatible API calls
Real DashScope tool calling to OTLP combined verification
Real Qwen-VL image URL to OTLP combined verification
Event Log input/output multi-modal metadata to OTLP combined verification
Independent ARMS Node.js probe + OpenAI 5 automatic instrumentation verification
OTLP HTTP export
Tool message conversion
Multi-modal URI,
mime_type, and automatic metadata verificationNormal trace and error trace unit tests
context.with()verification of automatic child Span parent-child relationships
This document uses a fixed commit reference for the verified Demo to prevent documentation code and npm package behavior drift caused by subsequent repository changes. When updating the verification baseline in this document, also update the Demo's fixed tag or commit, and redo the verification. The Demo must not contain API keys, OTLP authentication headers, internal projects, CLI profiles, or historical traceIds.