Node.js LLM 应用自定义埋点最佳实践

更新时间:
复制 MD 格式

@loongsuite/otel-util-genai 用于创建符合 ARMS GenAI 语义规范的 OpenTelemetry Span。它负责生成核心 GenAI Span(Entry、Agent、ReAct Step、LLM、Tool)和扩展 GenAI Span(Embedding、Retrieval、Rerank、Memory),并写入模型、消息、Token、工具调用等语义属性。

本文重点说明最容易影响接入结果的三件事:

  1. 如何在"ARMS 探针自动采集"和"util 完全手动接入"之间选择。

  2. 如何保证 ENTRY -> AGENT -> STEP -> LLM/TOOL 的调用链关系正确。

  3. 如何在发布前验证属性、Token、消息内容和异常状态确实已上报。

说明

手动埋点示例基于 Node.js 20/22 LTS、@loongsuite/otel-util-genai@0.1.0 和 OpenTelemetry JS 1.30.x 验证;探针示例基于 Node.js 20、@loongsuite/cms_node_sdk@1.0.4openai@5.23.2 验证。该工具要求 Node.js 20 或更高版本。生产环境建议使用 LTS 版本。

1. 接入模式选择

@loongsuite/cms_node_sdk 探针和 @loongsuite/otel-util-genai 当前使用不同的 Trace/Context API。不要在同一进程中把二者当成同一套 Provider 组合使用,也不要初始化两套导出链路。

场景

Trace 和 Exporter 由谁初始化

是否使用本 util

能得到的 GenAI Span

使用 @loongsuite/cms_node_sdk@1.0.4 自动采集受支持的模型 SDK

ARMS Node.js 探针

探针自动生成的 LLM Span

需要完整的 Entry、Agent、Step、LLM、Tool 等自定义层级

应用初始化标准 OpenTelemetry JS

应用按实际业务创建完整层级

1.1 使用 ARMS Node.js 探针自动采集

如果需求只是自动采集模型 SDK 调用,可使用 ARMS Node.js 探针。以下组合已经用 Node.js 20、真实 DashScope 请求和 ARMS 服务端数据验证:

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"
# 默认 workspace 不需要设置;非默认 workspace 才设置
# export ARMS_WORKSPACE="<workspace>"

node -r @loongsuite/cms_node_sdk/register app.js

ARMS_LICENSE 是凭证,不能写入源码、镜像、日志或文档。服务名应在同一环境中唯一且稳定。

@loongsuite/cms_node_sdk@1.0.4 的 OpenAI 自动埋点声明支持 openai >=4 <6。OpenAI 6 不在该版本范围内;即使模型请求成功,也不能据此判断 LLM Span 已生成。升级 OpenAI 或探针后,必须重新做服务端验收。

此探针的预加载入口面向常驻服务。新服务首次启动时,探针还需要完成服务配置握手;CLI、一次性脚本、极短的 Serverless 进程可能在握手或批量导出前退出。短任务需要完整自定义层级或确定性 flush 时,请使用 1.2 的手动 OTLP 模式。

说明

兼容边界:@loongsuite/cms_node_sdk@1.0.4 使用 CMS Trace/Context,并不会注册 @opentelemetry/api 的全局 TracerProvider。因此,直接调用 getExtendedTelemetryHandler() 会得到标准 OpenTelemetry API 一侧的默认 Handler,不能自动复用该探针,也不能保证父子关系和导出。本文不把这种组合列为受支持接入方式。

探针已经自动创建 LLM Span 时,不要再为同一次请求调用 startLlm()。如果还需要 Entry、Agent、Step、Tool 等业务 Span,请选择 1.2,用一套标准 OpenTelemetry Provider 完成全部埋点。

1.2 使用 util 完全手动接入

应用初始化一个标准 OpenTelemetry JS TracerProvider、SpanProcessor 和 OTLP Exporter,并把该 provider 显式传给 ExtendedTelemetryHandler

这种模式下,应用手工创建所需 GenAI Span,并在退出前等待 forceFlush()shutdown()。本文后续代码以该模式为主。

2. 前提条件

  • 已开通并完成ARMS 应用监控接入

  • 使用 Node.js 20 或 22 LTS。

  • 已从 ARMS 控制台取得当前应用和地域对应的 OTLP HTTP 接入地址及鉴权 Header。

  • 已确认应用的 service.name 在同一环境中唯一且稳定。

  • 埋点字段遵循LLM Trace 字段定义说明

不要把 API Key、OTLP 鉴权 Header、完整用户输入或其他敏感数据写入源码和日志。

3. 安装依赖

为了保证示例可复现,本文固定安装已验证的正式版本,不使用裸包名。本文的验证基线是 @loongsuite/otel-util-genai@0.1.0;升级到后续版本后,必须按照第 17 节重新验证依赖安装、Span 树、属性和 OTLP 导出。所有预发布版本均不作为本文的生产接入基线。

npm install @loongsuite/otel-util-genai@0.1.0

手动接入还需安装已经验证的 OpenTelemetry 1.30.x SDK 和 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 当前 peer dependency 位于 OpenTelemetry JS 1.x 版本线。不要在没有完整兼容性验证的情况下把本文示例直接替换为 OpenTelemetry SDK 2.x。

3.1 OpenTelemetry 1.x 的安全边界

本文固定的 1.30.1 组合是 @loongsuite/otel-util-genai@0.1.0 的兼容性验收基线,不代表它包含 OpenTelemetry 2.x 的全部后续安全修复。依赖扫描会报告以下两个问题:

  • @opentelemetry/core <2.8.0 的 W3C Baggage 入站解析没有在传播器内限制总大小和条目数(CVE-2026-54285)。Node.js 默认 16 KiB HTTP Header 上限会降低普通 HTTP 暴露面;网关仍应限制 Header 大小。自定义消息传输、提高 Header 上限或自定义 TextMapGetter 时,应在调用传播器前限制不可信 baggage 的大小和条目数。

  • @opentelemetry/propagator-jaeger <2.9.0 在解析畸形 uber-trace-iduberctx-* 时可能导致进程退出(CVE-2026-59892)。该包会作为 @opentelemetry/sdk-trace-node 的传递依赖安装,但本文没有启用 Jaeger Propagator;默认 W3C TraceContext/Baggage 配置不受该问题影响。不要在此 1.x 组合中设置 OTEL_PROPAGATORS=jaeger;如果历史系统必须接收 Jaeger Header,应在网关过滤或校验这些 Header,并规划经过完整兼容性测试的升级。

不要只为消除扫描告警就把部分依赖单独提升到 2.x,这可能造成 API/SDK 版本错配。应把升级作为独立变更,重新验证单元测试、OTLP 导出和 ARMS 服务端链路。安全公告见 GHSA-8988-4f7v-96qfGHSA-45rx-2jwx-cxfr

4. 配置 Resource 和内容采集策略

4.1 Resource

至少设置稳定的服务名,并为 ARMS 标记 GenAI 应用和埋点来源:

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.featuregen_ai.instrumentation.sdk.name 是 Resource Attribute,不是普通 Span Attribute。若已经设置 OTEL_RESOURCE_ATTRIBUTES,请使用英文逗号追加,不能覆盖现有的 service.namespacedeployment.environment.name 等属性。

4.2 消息内容采集

本节环境变量只控制 util 的消息记录。应用直接使用 util 时,未设置环境变量会按 NO_CONTENT 处理,不采集完整消息内容。应在启动进程前显式设置所需模式:

export OTEL_SEMCONV_STABILITY_OPT_IN="gen_ai_latest_experimental"
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT="SPAN_ONLY"

可选值如下:

Span 属性

Event

建议用途

NO_CONTENT

不记录

不记录

默认生产配置

SPAN_ONLY

记录

不记录

调试或经脱敏的业务

EVENT_ONLY

不记录

记录

已配置 OTel Logs 时使用

SPAN_AND_EVENT

记录

记录

仅在确认重复存储和合规风险后使用

消息内容可能包含个人信息、业务机密、提示词和工具参数。生产环境开启前,应先完成脱敏、权限和保存周期评估。

@loongsuite/cms_node_sdk@1.0.4 的 OpenAI 自动埋点使用探针自己的内容采集配置,不能用上述 util 环境变量推断其行为。该版本默认会记录 OpenAI 请求和响应内容;如果业务不允许内容进入 Trace,又不能在探针侧确认关闭,请改用手动模式并保持 NO_CONTENT

5. 完全手动接入时初始化 OpenTelemetry

以下初始化文件必须先于业务模块加载:

// 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();
}

从 ARMS 控制台复制接入参数。变量名称和 URL 形式以控制台给出的示例为准,不要自行拼接地域、路径或鉴权参数。

export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="<控制台提供的 traces endpoint>"
export OTEL_EXPORTER_OTLP_HEADERS="<控制台提供的鉴权 Header>"

如果控制台给出的是通用 OTEL_EXPORTER_OTLP_ENDPOINT,应按控制台示例使用该变量。

应用入口必须先加载 telemetry.mjs,并在进程退出前刷新数据:

import {
  handler,
  shutdownTelemetry,
} from "./telemetry.mjs";
import { runRequest } from "./agent.mjs";

try {
  await runRequest({ handler });
} finally {
  await shutdownTelemetry();
}

ConsoleSpanExporter 只能用于本地观察,不能把数据发送到 ARMS。

6. Span 类型与命名

Span 名称和 gen_ai.operation.name 是两个不同字段,不应混用。

类型

Span 名称

gen_ai.operation.name

工厂函数

Entry

enter_ai_application_system

enter

createEntryInvocation()

Agent

invoke_agent {agentName}

invoke_agent

createInvokeAgentInvocation()

ReAct Step

react step

react

createReactStepInvocation()

LLM

chat {model}

chat

createLLMInvocation()

Tool

execute_tool {toolName}

execute_tool

createExecuteToolInvocation()

Embedding

embeddings {model}

embeddings

createEmbeddingInvocation()

Retrieval

retrieval {dataSourceId}

retrieval

createRetrievalInvocation()

Rerank

rerank_documents {model}

rerank_documents

createRerankInvocation()

Memory

memory_operation {operation}

memory_operation

createMemoryInvocation()

典型 Agent 请求的链路应为:

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-plus

Tool 是否与 LLM 同属一个 Step,取决于应用的实际执行模型。不要为了得到固定截图而伪造父子关系。

7. Node.js 上下文传播规则

这是 Node.js 接入中最重要的规则。

7.1 不会自动激活上下文

startXxx() 会把新 Span 对应的 OTel Context 保存到 invocation.contextToken,但不会把它自动设为 context.active()

创建手工子 Span 时,必须把父 invocation 的 contextToken 作为第二个参数传入:

handler.startInvokeAgent(agentInv, entryInv.contextToken);
handler.startReactStep(stepInv, agentInv.contextToken);
handler.startLlm(llmInv, stepInv.contextToken);
handler.startExecuteTool(toolInv, stepInv.contextToken);

调用可能被自动埋点的模型 SDK、HTTP 客户端或数据库时,还必须使用 context.with() 激活对应上下文:

import { context } from "@opentelemetry/api";

const response = await context.with(
  llmInv.contextToken,
  () => modelClient.chat.completions.create(request),
);

否则,自动生成的 SDK/HTTP Span 可能成为 LLM Span 的兄弟节点,甚至生成另一条 trace。

7.2 公共属性通过 Baggage 向下继承

Entry 上的 sessionIduserId 和 Agent 上的 agentName 会写入 OTel Baggage。以它们的 contextToken 为父上下文创建的后续 GenAI Span,可以继承:

  • gen_ai.session.id

  • gen_ai.user.id

  • gen_ai.agent.name

继承依赖正确的 parent Context。父子关系断开时,这些公共属性也可能缺失。

7.3 为何本文使用显式 start/stop

Handler 也提供 entry()invokeAgent()llm() 等回调式接口,但回调式接口本身不会自动以新 invocation 的 Context 执行回调。

只要回调内部还会调用自动埋点 SDK,或需要创建多层 GenAI Span,就应使用显式的 startXxx()context.with()stopXxx()/failXxx(),使上下文边界一目了然。

8. 创建 Entry、Agent 和 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: "先查询天气工具,再回答用户问题。",
  requestModel: "qwen-plus",
});
handler.startInvokeAgent(agentInv, entryInv.contextToken);

const stepInv = createReactStepInvocation({ round: 1 });
handler.startReactStep(stepInv, agentInv.contextToken);

完成时从内向外结束:

stepInv.finishReason = "stop";
handler.stopReactStep(stepInv);

agentInv.inputTokens = totalInputTokens;
agentInv.outputTokens = totalOutputTokens;
agentInv.outputMessages = finalOutputMessages;
handler.stopInvokeAgent(agentInv);

entryInv.outputMessages = finalOutputMessages;
handler.stopEntry(entryInv);

如果上游返回可靠的 total_tokens,可以显式设置 totalTokens。未设置时,工具会根据 input 和 output Token 计算总量。

9. LLM Span:自动采集和手工采集二选一

9.1 模型 SDK 已被同一标准 OpenTelemetry Provider 自动采集

本节只适用于自动埋点和 util 明确复用同一个 @opentelemetry/api TracerProvider 的场景,不适用于 1.1 的 @loongsuite/cms_node_sdk@1.0.4

不要再创建手工 LLM Span。只需让模型调用在 Step 的 Context 中执行:

const response = await context.with(
  stepInv.contextToken,
  () => modelClient.chat.completions.create(request),
);

发布前必须确认一次模型请求只有一个 LLM Span,并确认该自动 LLM Span 的 parentSpanId 指向对应 Step。

9.2 完全手工创建 LLM Span

以下代码中的辅助函数 toGenAIInputMessagestoGenAIToolDefinitions 定义见第 10 节,toGenAIOutputMessage 定义见第 10 节,toSafeGenAIError 定义见第 12 节。

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;
}

不要在发起请求前结束 LLM Span;否则耗时、自动子 Span 和异常状态都会失真。

10. 正确转换 OpenAI 兼容消息

不能只转换 { role, content }。Agent 的第二轮模型输入还包含 assistant tool_calls 和 role 为 tool 的工具结果;丢失这些字段会导致 ARMS 无法还原一次完整工具调用。

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 属性和消息 JSON 使用不同的 finish reason 取值。不要对两者应用同一转换:

模型返回 choice.finish_reason = "tool_calls"

gen_ai.response.finish_reasons = ["tool_calls"]
output message.finish_reason    = "tool_call"

gen_ai.response.finish_reasons 应保留模型提供商返回的原始值;只有写入 output message Schema 时,才把复数 tool_calls 转成单数 tool_call

toolCall.function.arguments 通常是 JSON 字符串。除非业务已经成功解析并希望按对象记录,否则应保留模型的原始值,避免埋点数据与实际请求不一致。

11. 创建 Tool Span

import {
  createExecuteToolInvocation,
} from "@loongsuite/otel-util-genai";

const toolInv = createExecuteToolInvocation(
  toolCall.function.name,
  {
    toolCallId: toolCall.id ?? null,
    toolDescription: "查询指定城市的天气",
    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;
}

工具调用完成后,还应把结果以 OpenAI 兼容格式追加到下一轮输入:

messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: result,
});

toolCallId 必须在 assistant tool call、Tool Span 和 tool response 三处保持一致。

toolCallArgumentstoolCallResult 会直接写入 Tool Span,不受消息内容采集开关控制。设置这两个字段前必须完成脱敏;不允许上报的内容应省略,而不是寄希望于 NO_CONTENT 自动过滤。

12. 异常处理必须逐层收口

每个已经开始且仍在 recording 的 invocation 都必须调用一次对应的 stopXxx()failXxx()

原始异常消息可能包含模型响应体、请求参数、文件路径或凭证。failXxx() 会把传入的 message 写入 Span Status,因此不能直接传入 error.messageString(error)。应使用固定的安全消息,并只保留经过约束的错误类型:

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 };
}

嵌套调用失败时,应从内向外标记:

failLlm / failExecuteTool
  -> failReactStep
    -> failInvokeAgent
      -> failEntry

推荐在外层 catch 中先检查:

if (stepInv.span?.isRecording()) {
  handler.failReactStep(
    stepInv,
    toSafeGenAIError(
      error,
      "StepError",
      "Agent step failed",
    ),
  );
}

这样可以避免已经结束的 Span 被重复结束。failXxx() 会记录错误状态和 error.type,调用后仍需重新抛出原始异常,不能吞掉业务错误。应用如需记录日志,也应使用固定安全消息或经过审核的脱敏字段,不能直接输出原始异常。

13. 创建普通业务 Span

不属于 GenAI 语义的内部操作使用原生 OTel Span。例如在 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();
}

自定义属性应使用业务命名空间,例如 app.*。不要用自定义值覆盖 gen_ai.*server.*error.type 等由工具维护的字段。

14. 其他 GenAI Span 类型

Embedding、Retrieval、Rerank、Memory 等扩展 Span 类型遵循同一生命周期:创建 invocation、传入父 Context、在真实操作完成后补充结果、正常或异常结束。异常处理模式与第 12 节一致(try/catch + failXxx()),以下示例省略异常路径以突出核心字段。

const embeddingInv = createEmbeddingInvocation(
  "text-embedding-v3",
  { provider: "dashscope" },
);
handler.startEmbedding(embeddingInv, stepInv.contextToken);
// 调用 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);

14.1 多模态输入(图片 URL)

应用发送给模型的请求对象和写入 GenAI Span 的消息对象是两套 Schema,应分别构造。以下示例使用模型实际接收的图片 URL,因此在 GenAI 消息中映射为 Uri Part:

const prompt = "请用一句话描述这张图片。";
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;
}

TypeScript 公共 API 使用 mimeType。在 SPAN_ONLYSPAN_AND_EVENT 模式下,0.1.0 会自动生成:

gen_ai.input.messages
gen_ai.input.multimodal_metadata

两个属性中的 URI 数据都使用 Schema 字段 mime_type

[
  {
    "type": "uri",
    "mime_type": "image/jpeg",
    "uri": "https://example.com/image.jpg",
    "modality": "image"
  }
]

multimodal metadata 只汇总最终消息中的 Uri Part,不包含 Text、Blob、Base64Blob 或 File。该工具只记录遥测数据,不负责上传媒体、调用模型或把 provider 文件对象转换成 URI。

14.2 File Part 的支持边界

File Part 在 TypeScript 中使用:

{
  type: "file",
  mimeType: "application/pdf",
  modality: "document",
  fileId: "file-123",
}

写入消息 JSON 时会转换为 mime_typefile_id。但是不同模型提供商对文件上传和 file ID 引用的接口差异较大,本文没有把 File Part 作为已经完成真实模型端到端验证的示例。只有在应用确实使用 provider 返回的 file ID 发起模型请求时,才应按实际请求映射该 Part;不要为了展示字段而构造虚假的 file ID。

14.3 Event Log 的字段形式

直接调用 TypeScript 工厂函数时使用 camelCase:

{ type: "uri", mimeType: "image/png", modality: "image", uri }
{ type: "file", mimeType: "application/pdf", modality: "document", fileId }

Event Log 是线上的 JSON Schema,输入记录必须使用 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"
}

不要把 TypeScript 对象字段和 Event Log Schema 混用。convertEventLogToTrace() 会根据最终 input/output messages 分别生成:

gen_ai.input.multimodal_metadata
gen_ai.output.multimodal_metadata

两个 metadata 都只汇总各自消息中的 URI Part。配套 Event Log Demo 使用 image/png 输入 URI 和 image/webp 输出 URI 验证双向序列化;它验证的是 Event Log 转换和 OTLP 上报,不代表某个模型真实生成了图片。

15. 本地测试和端到端验证

15.1 离线单元测试

测试不应只断言"生成了 Span",还应检查:

  • 所有 Span 的 traceId 相同。

  • Agent 的 parentSpanId 指向 Entry。

  • Step 的 parentSpanId 指向 Agent。

  • LLM 和 Tool 的 parentSpanId 指向对应 Step。

  • 自动生成的 SDK/HTTP Span 位于手工 LLM 或 Tool Span 下。

  • gen_ai.session.idgen_ai.user.idgen_ai.agent.name 已继承。

  • LLM 和 Agent 的 Token 值正确。

  • assistant tool call 和 tool response 都被保留。

  • 多模态字段使用 mime_type / file_id,没有残留 camelCase。

  • multimodal metadata 只包含 URI Part。

  • 异常链上的 Span 均为 Error。

下载本文配套 Demo 后执行:

cd nodejs-genai-util-demo
npm ci
npm test
npm run demo

Demo 的 package.json 应固定依赖本文验证的 npm 版本。对客 Demo 不应依赖 file:../... 本地源码路径。

15.2 真实模型验证

export DASHSCOPE_API_KEY="<your-api-key>"
npm run demo:dashscope

这一步使用内存 Exporter,目的是单独确认模型、Tool Calling 和消息转换正确。检查模型确实完成了工具调用,并记录程序输出的 traceId。不要在日志中打印 API Key。

15.3 Tool Calling 到 OTLP 的组合验证

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:e2e

GENAI_DEMO_ALLOW_CONTENT_EXPORT=true 是配套 Demo 的安全确认开关,表示操作者确认示例中的完整输入和输出可以发送到所配置的 OTLP 后端;它不会自动脱敏。只应使用公开、虚构或已经脱敏的数据。

只有 forceFlush()shutdown() 都成功后,Demo 才输出:

export completed traceId=<trace-id>

导出器返回成功只能证明客户端已完成导出请求,不能替代 ARMS 控制台验收。

如果控制台给出的是 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,应按控制台示例使用该变量,不能自行改写 URL。

15.4 图片 URL 到 OTLP 的组合验证

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-e2e

图片必须是不含敏感内容的公开 HTTPS URL,不得包含用户名、密码、query、fragment 或临时签名参数。完整 URI 会同时写入 gen_ai.input.messagesgen_ai.input.multimodal_metadata

程序应输出真实模型回答、util.version、模型名、response ID、finish reason、Token 和 traceId。模型回答必须体现图片内容;只得到 HTTP 200 不能证明模型读取了图片。

15.5 Event Log 到 OTLP 的组合验证

这个用例不调用模型,只验证 Event Log 转换、input/output 多模态字段和 OTLP 上报:

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-e2e

程序应输出 span.count=4input.mime_type=image/pngoutput.mime_type=image/webp 和 traceId。服务端应得到 ENTRY -> AGENT -> STEP -> LLM,并同时存在 gen_ai.input.multimodal_metadatagen_ai.output.multimodal_metadata

15.6 ARMS Node.js 探针自动采集验证

这个用例与手动 util Demo 分开安装,避免混入第二套 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"
# 默认 workspace 不需要设置
# export ARMS_WORKSPACE="<workspace>"

node -r @loongsuite/cms_node_sdk/register app.js

配套验证程序固定 @loongsuite/cms_node_sdk@1.0.4openai@5.23.2,默认等待 65 秒完成新服务的首次配置握手,再真实调用 DashScope。已注册服务可以设置 PROBE_WARMUP_MS=0 缩短验证。

服务端至少应得到 HTTP SERVER → openai.chat LLM → 模型 HTTP CLIENT 的父子关系。openai.chat 应包含:

  • gen_ai.span.kind=LLMgen_ai.operation.name=chat

  • request/response model、response ID 和 finish reason。

  • input/output/total Token。

  • 输入和输出消息(仅在业务允许记录内容时)。

  • otel.scope.name=openaiotel.scope.version=1.0.4

  • Resource 中的 telemetry.sdk.name=cms_node_sdk 和正确 service.name

如果日志出现 BatchSpanProcessor: span export failed、HTTP 404/403,或 ARMS 中查不到服务,不得仅凭模型返回 200 判定接入成功。确认 License、地域、workspace 和服务配置一致,并为新服务保留首次配置握手时间;短任务改用手动 OTLP 模式。

15.7 验证接入结果

使用输出的 traceId 在 ARMS 控制台的LLM 应用监控页面下的调用链分析逐项确认:

  1. trace 能在正确地域、正确应用和正确 service.name 下检索到。

  2. 调用链树与业务执行顺序一致。

  3. 一个模型请求只有一个 LLM Span。

  4. 每个 GenAI Span 均有正确的 gen_ai.span.kindgen_ai.operation.name

  5. LLM 包含 gen_ai.request.model、输入/输出/总 Token。

  6. Agent Token 等于其包含的模型调用汇总。

  7. Tool 包含名称、call ID、参数和结果。

  8. 开启内容采集后,第二轮 LLM 输入包含 tool call 与 tool response。

  9. Entry、Agent、Step、LLM、Tool 都有 session/user/agent 公共属性。

  10. 失败用例中的所有打开 Span 都标记为 Error。

  11. 图片验证的 gen_ai.input.messages 包含 type=urimime_typemodality 和真实 URL。

  12. gen_ai.input.multimodal_metadata 是 JSON 数组,且只汇总 URI Part。

  13. Event Log 验证的 gen_ai.output.messagesgen_ai.output.multimodal_metadata 包含输出 URI,并使用 mime_type

  14. 消息 JSON 和 metadata 中没有残留 mimeType / fileId

  15. otel.scope.version 与实际安装的 npm 版本一致。

  16. Resource 中存在 service.nameacs.arms.service.feature=genai_appgen_ai.instrumentation.sdk.name=loongsuite-genai-utils

网站示例维护方还应在 Node.js 20 和 22 的干净目录中安装相同 npm 版本分别复验。未完成服务端查询、仍依赖本地源码或版本不一致时,不能把示例标记为已验证。

16. 常见问题

ARMS 中完全没有数据

  • 检查使用的 endpoint、地域和鉴权 Header 是否来自同一应用。

  • 检查 service.name

  • 确认使用的是 OTLP Exporter,而不是 ConsoleSpanExporter。

  • 确认进程退出前执行了 forceFlush()shutdown()

  • 检查 Exporter 错误日志和网络出口。

使用 ARMS Node.js 探针时,还要检查 ARMS_LICENSEARMS_REGION_IDARMS_WORKSPACECMS_SERVICE_NAME。新服务首次启动应保留配置握手时间;模型调用成功但出现 exporter 404/403,仍属于上报失败。

ARMS 探针下没有 openai.chat

  • 确认 Node.js 启动参数确实包含 -r @loongsuite/cms_node_sdk/register,并且探针先于 openai 加载。

  • @loongsuite/cms_node_sdk@1.0.4 只声明支持 openai >=4 <6,OpenAI 6 不在已验证范围。

  • 检查启动日志是否列出 openai instrumentation。

  • 用 ARMS 服务端数据确认,而不是只看模型响应。

ExtendedTelemetryHandler 没有导出 Span

@loongsuite/cms_node_sdk@1.0.4 没有注册标准 @opentelemetry/api Provider,不能被默认 Handler 直接复用。不要把两条路线混接;需要 util 时,按第 5 节初始化标准 OpenTelemetry Provider 和 OTLP Exporter。

同一次模型调用出现两个 LLM Span

通常是模型 SDK 已被自动埋点采集,同时业务又调用了 startLlm()。同一 Provider 中只保留一种 LLM 埋点;@loongsuite/cms_node_sdk@1.0.4 和 util 则应按第 1 节完全分开接入。

Span 在同一条 trace 中,但层级错误

  • 手工子 Span 是否显式传入父 contextToken

  • 自动埋点操作是否在 context.with() 内执行。

  • 是否跨越了未正确传播 AsyncLocalStorage Context 的自定义异步边界。

子 Span 缺少 session、user 或 agent

检查 Entry/Agent 是否设置了对应字段,并确认子 Span 使用了父 invocation 的 contextToken

看不到输入和输出消息

确认环境变量在 Node.js 进程启动前设置为:

OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY

然后检查消息转换结果是否符合 { role, parts } 结构。

有图片消息,但没有 multimodal metadata

  • 确认安装的是本文已验证的正式版 0.1.0;使用后续版本时应重新执行第 17 节的完整验证。

  • 确认内容采集模式为 SPAN_ONLYSPAN_AND_EVENT

  • metadata 只汇总 type: "uri" 的 Part,不汇总 Blob、Base64Blob 或 File。

  • 直接调用 TypeScript API 时检查 mimeTypemodalityuri

  • Event Log 输入时检查 Schema 字段 mime_typemodalityuri

  • 检查是否通过 invocation.attributes 显式覆盖了自动生成的属性。

File Part 没有出现在 multimodal metadata

这是预期行为。File Part 会保留在消息 JSON 中,并把 fileId 序列化为 file_id,但 URI metadata 只汇总 Uri Part。只有 provider 的真实请求确实使用 file ID 时,才应记录 File Part。

安装时出现 ERESOLVE

检查项目是否混用了 OpenTelemetry SDK 1.x 和 2.x。本文验证组合为 API 1.9.1、trace/resources 1.30.1、OTLP exporter 0.57.2。

短任务偶发丢 Span

手动 OTLP 模式不要依赖进程自然退出。CLI、脚本、Serverless handler 和测试程序都应显式等待 forceFlush()/shutdown()

@loongsuite/cms_node_sdk/register 的信号处理会在 SIGINT/SIGTERM 时执行 shutdown,但新服务还需要首次配置握手。对极短任务,优先使用可显式控制生命周期的手动 OTLP 模式。

17. 相关资料

18. 配套 Demo

nodejs-genai-util-demo 包含:

  • 离线模型和内存 Exporter。

  • 真实 DashScope OpenAI 兼容接口调用。

  • 真实 DashScope Tool Calling 到 OTLP 的组合验证。

  • 真实 Qwen-VL 图片 URL 到 OTLP 的组合验证。

  • Event Log input/output 多模态 metadata 到 OTLP 的组合验证。

  • 独立的 ARMS Node.js 探针 + OpenAI 5 自动埋点验证。

  • OTLP HTTP 导出。

  • Tool 消息转换。

  • 多模态 URI、mime_type 和自动 metadata 验证。

  • 正常链路和异常链路单元测试。

  • context.with() 对自动子 Span 父子关系的验证。

本文使用固定 commit 引用已经验收的 Demo,避免仓库后续变更导致文档代码和 npm 包行为漂移。更新本文验证基线时,应同步更新 Demo 的固定 tag 或 commit,并重新完成第 17 节的验证。Demo 中不得包含 API Key、OTLP 鉴权 Header、内部 Project、CLI profile 或历史 traceId。