为 Java 应用传递 GenAI 业务属性

更新时间:
复制 MD 格式

大模型应用的一次用户请求通常横跨入口服务、Agent 编排、模型调用、工具调用、检索和数据库访问等多个 Span。用户 ID、会话 ID 等业务信息默认只存在于业务代码里,导致无法按业务维度检索和聚合整条调用链。本文介绍如何为 Java 应用复用 Java 探针自动创建的 HTTP/RPC 入口 Span,通过 OpenTelemetry Baggage 把业务属性透传到所有后续子 Span,实现业务维度可检索的全链路观测。

方案架构

方案由两条互补的染色路径组成,共同覆盖入口 Span 与其之后的所有 Span:

  • 入口 Span 直接染色:由业务代码调用 Span.current().setAttribute(...),把 gen_ai.user.id、gen_ai.session.id 等属性写入探针已经创建的当前 HTTP/RPC 入口 Span。入口 Span 在 Controller 方法执行前就已经启动,Baggage 处理只在 Span 启动时生效,不会追溯修改已启动的入口 Span,因此必须显式 setAttribute。

  • 后续 Span 通过 Baggage 染色:把相同属性写入当前 Context 的 Baggage,并为 Java 探针配置待采集 Baggage Key。此后创建的每一个子 Span 在启动时都会读取 Baggage,并写入同名 Span 属性。

    两条路径合力后,链路层级保持原样、不额外增加 GenAI 入口 Span,同时让 GenAI、HTTP、RPC、数据库等后续 Span 都携带业务属性。

前提条件

开始前请确认以下条件全部满足。跨服务场景涉及下游应用与中间链路,尤其需要按最后一行整体确认。

项目

要求

探针版本

4.7.0 及以上版本。

接入状态

已能在控制台查看该应用的调用链数据。

API 依赖

业务代码编译时可使用 OpenTelemetry API(opentelemetry-api)。

跨服务前提

若链路跨应用传播,需同时满足:下游应用也接入 Java 探针;下游应用配置相同的待采集 Baggage Key。

步骤一:配置待采集 Baggage Key

在控制台进入目标应用,选择应用配置 > 自定义配置,在探针采集配置区域找到待采集 baggage 列表,填入需要染色的 Key,多个 Key 使用英文逗号分隔:

gen_ai.user.id,gen_ai.session.id

也可以通过 JVM 启动参数配置:

-Dapsara.apm.trace.capture_baggage_keys=gen_ai.user.id,gen_ai.session.id

探针只复制明确配置的 Key,Key 大小写敏感。配置项对应的内部键为 apsara.apm.trace.capture_baggage_keys。

步骤二:在请求入口设置属性和 Baggage

业务代码需要在活动入口 Span 已存在、后续子 Span 尚未创建的窗口内完成设置。以下示例演示一个 REST API 的完整写法:

import io.opentelemetry.api.baggage.Baggage;
import io.opentelemetry.api.baggage.BaggageBuilder;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
import java.util.HashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GenerateController {

  private static final String USER_ID = "gen_ai.user.id";
  private static final String SESSION_ID = "gen_ai.session.id";

  @PostMapping("/api/v1/generate")
  public Map<String, String> generate(@RequestBody Map<String, String> request) {
    String userId = request.get("userId");
    String sessionId = request.get("sessionId");
    String prompt = request.get("prompt");

    Span currentSpan = Span.current();
    BaggageBuilder baggageBuilder = Baggage.current().toBuilder();

    putAttributeAndBaggage(currentSpan, baggageBuilder, USER_ID, userId);
    putAttributeAndBaggage(currentSpan, baggageBuilder, SESSION_ID, sessionId);

    Context baggageContext =
        baggageBuilder.build().storeInContext(Context.current());

    try (Scope ignored = baggageContext.makeCurrent()) {
      // 在此作用域中调用 LangGraph4j、LangChain4j 或其他业务逻辑。
      // 后续创建的 GenAI、HTTP、RPC、数据库等 Span 会继承 Baggage。
      String result = doBusiness(prompt);

      Map<String, String> response = new HashMap<>();
      response.put("result", result);
      return response;
    }
  }

  private static void putAttributeAndBaggage(
      Span span, BaggageBuilder builder, String key, String value) {
    if (value == null || value.trim().isEmpty()) {
      return;
    }
    span.setAttribute(key, value);
    builder.put(key, value);
  }

  private String doBusiness(String prompt) {
    // 替换为实际的模型或 Agent 调用。
    return "response for: " + prompt;
  }
}

使用以下命令发起测试请求:

curl -X POST http://localhost:8080/api/v1/generate \
  -H "Content-Type: application/json" \
  -d '{"userId":"user-001","sessionId":"session-001","prompt":"什么是 OpenTelemetry?"}'
说明

示例为便于测试直接从请求体读取 userId 和 sessionId。生产环境应优先从认证后的用户上下文中获取,避免直接信任外部输入。

为什么入口 Span 和后续 Span 要分别处理?

Java 探针通常会在执行 REST Controller 方法前创建 HTTP Server Span。待采集 Baggage 的处理发生在 Span 启动时,因此 Controller 方法中新写入的 Baggage 不能追溯修改已经启动的入口 Span。

所以需要同时执行:

  1. Span.current().setAttribute(...):补充当前入口 Span。

  2. Baggage + makeCurrent():让之后创建的子 Span 继承业务属性。

步骤三:处理异步和跨服务调用

异步调用

步骤二的示例中,doBusiness(prompt) 与 Controller 方法在同一线程内同步执行,并且位于 Baggage Scope 内,无需额外处理。只有把业务任务提交到其他线程时,才需要考虑异步传播,例如:

try (Scope ignored = baggageContext.makeCurrent()) {
  // 此处仍在请求线程中,Context.current() 包含 userId 和 sessionId。
  Context contextWithBaggage = Context.current();

  executor.execute(contextWithBaggage.wrap(() -> {
    // 任务实际在线程池工作线程中执行。
    // wrap 会在任务执行期间恢复提交任务时捕获的 Context。
    doAsyncBusiness();
  }));
}

执行顺序如下:

  1. 请求线程进入 Baggage Scope。

  2. 在 Scope 内通过 Context.current() 捕获包含业务属性的 Context。

  3. 使用 contextWithBaggage.wrap(...) 包装任务并提交到线程池。

  4. Controller 线程退出 Scope,不会把业务属性泄漏给后续无关请求。

  5. 工作线程开始执行任务时,wrap(...) 临时恢复此前捕获的 Context;任务结束后再恢复工作线程原来的 Context。

如果直接提交未包装的任务:

executor.execute(() -> doAsyncBusiness());

如果线程池未被探针自动增强,工作线程无法看到请求线程里的 Baggage,异步任务里创建的 Span 就不会带上 gen_ai.user.id 和 gen_ai.session.id。

对于探针已支持的常见线程池,探针通常会自动完成上述 Context 捕获与恢复。显式调用 Context 实例的 wrap(...) 方法主要用于自建线程、未被探针支持的异步组件,或明确希望验证传播行为的场景。

跨服务调用

如果链路仅在单个应用内,跳过本节。对于探针已支持的 HTTP/RPC 客户端,Baggage 会随链路上下文注入下游请求,并由下游探针提取。要让下游 Span 同样出现业务属性,需要:

  • 下游应用也接入 Java 探针。

  • 下游应用配置相同的待采集 Baggage Key。

  • 中间网关、代理或框架不能删除 Baggage 传播头。

  • 业务属性必须在发起下游调用之前写入当前 Context。

预期效果

配置生效并产生新请求后:

  • 当前 HTTP/RPC 入口 Span 包含 gen_ai.user.id、gen_ai.session.id 等属性。

  • Scope 中创建的 GenAI、HTTP、RPC、数据库等子 Span 包含相同属性。

  • 跨服务调用时,下游服务产生的新 Span 也包含配置的业务属性。

  • 链路不会额外增加一层 GenAI Entry Span,原有父子关系保持不变。

验证方法

  1. 使用固定测试值发起一次新请求,例如 gen_ai.user.id=test-user-001。

  2. 在控制台按时间范围找到对应 Trace。

  3. 检查 HTTP/RPC 入口 Span 是否包含该属性。

  4. 检查入口之后创建的 GenAI、HTTP、RPC 或数据库 Span 是否包含该属性。

  5. 若包含跨服务调用,继续检查下游应用 Span。

  6. 使用该属性作为筛选条件,确认可以定位到完整调用链。

常见问题

配置了待采集 baggage 列表,但入口 Span 没有属性怎么办?

入口 Span 在业务入口代码执行前已经启动,Baggage 处理不会追溯修改它。请确认代码同时调用了 Span.current().setAttribute(...),并且执行时 Span.current() 是有效的入口 Span。

入口 Span 有属性,但后续 Span 没有怎么办?

请依次确认:

  • Baggage Key 与控制台配置完全一致。

  • 已调用 storeInContext(...).makeCurrent()。

  • 目标 Span 是在 makeCurrent() 之后、Scope 关闭之前创建。

  • 目标应用使用支持该配置能力的探针版本。

下游服务没有属性怎么办?

确认调用框架支持上下文传播,下游应用配置了相同的待采集 Baggage Key,并检查网关或代理是否移除了 Baggage 传播头。只传播 Baggage 还不够,下游探针也必须将对应 Key 配置为待采集属性。

是否必须创建 GenAI 入口 Span?

不需要。本方案直接复用 Java 探针自动创建的 HTTP/RPC 入口 Span,不增加额外 Span。若产品后续要求独立的 GenAI 会话入口语义,再单独评估创建 GenAI 入口 Span。

忘记关闭 Scope 会有什么后果?

可能导致业务属性泄露到后续无关请求。请始终使用 try-with-resources,确保 Scope 在当前请求结束时关闭。

可以写入哪些业务属性?

建议只写入确实用于检索、聚合、排障或告警的短字符串字段。不要写入密码、Token、身份证号、手机号等敏感信息,也不要写入大对象或无分析价值的高基数字段。