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.4 和 openai@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-id 或 uberctx-* 时可能导致进程退出(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-96qf 和 GHSA-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.feature 和 gen_ai.instrumentation.sdk.name 是 Resource Attribute,不是普通 Span Attribute。若已经设置 OTEL_RESOURCE_ATTRIBUTES,请使用英文逗号追加,不能覆盖现有的 service.namespace、deployment.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 上的 sessionId、userId 和 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

以下代码中的辅助函数 toGenAIInputMessages、toGenAIToolDefinitions 定义见第 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 三处保持一致。

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

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

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

原始异常消息可能包含模型响应体、请求参数、文件路径或凭证。failXxx() 会把传入的 message 写入 Span Status,因此不能直接传入 error.message 或 String(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_ONLY 或 SPAN_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_type 和 file_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.id、gen_ai.user.id、gen_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.messages 和 gen_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=4、input.mime_type=image/png、output.mime_type=image/webp 和 traceId。服务端应得到 ENTRY -> AGENT -> STEP -> LLM,并同时存在 gen_ai.input.multimodal_metadata 和 gen_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.4 和 openai@5.23.2,默认等待 65 秒完成新服务的首次配置握手,再真实调用 DashScope。已注册服务可以设置 PROBE_WARMUP_MS=0 缩短验证。

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

  • gen_ai.span.kind=LLM 和 gen_ai.operation.name=chat。

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

  • input/output/total Token。

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

  • otel.scope.name=openai、otel.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.kind 和 gen_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=uri、mime_type、modality 和真实 URL。

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

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

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

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

  16. Resource 中存在 service.name、acs.arms.service.feature=genai_app 和 gen_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_LICENSE、ARMS_REGION_ID、ARMS_WORKSPACE 和 CMS_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_ONLY 或 SPAN_AND_EVENT。

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

  • 直接调用 TypeScript API 时检查 mimeType、modality 和 uri。

  • Event Log 输入时检查 Schema 字段 mime_type、modality 和 uri。

  • 检查是否通过 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。