构建跨技术栈全链路可观测性:从Angular到Express再到JPA的TraceID传递与日志关联实战


我们面临的第一个警报,来自一个工业资产监控平台的终端用户报告:仪表盘上的“设备状态”组件数据刷新偶发性超时。这个操作看似简单,背后却横跨了三个完全异构的技术栈:用户交互的Angular前端、作为API网关和业务编排的Express.js中间层,以及负责核心数据读写的Java后端服务,该服务通过JPA/Hibernate与PostgreSQL交互。

问题定位的初步尝试,是场灾难。我们在三个系统的日志文件中反复grep,试图通过时间戳和模糊的请求参数来拼凑出一次完整的用户请求链路。前端抛出的HTTP 504网关超时,在Express层可能对应着一连串对下游服务的调用日志,而Java后端则可能因为一次慢查询、一次数据库连接池耗尽或一次GC停顿而毫无响应。日志是孤立的,它们之间没有任何关联标识。这不仅仅是效率低下的问题,在真实生产环境中,这意味着故障平均恢复时间(MTTR)的不可控。

问题的根源在于可观测性的缺失。我们需要一种机制,能为每一次用户发起的请求生成一个全局唯一的标识,并让这个标识贯穿整个调用链路,最终将所有相关的日志、指标、追踪数据串联起来。这就是我们决定引入OpenTelemetry的原因。

初步构想与技术选型

我们的目标很明确:实现一个从浏览器端发起,经过Node.js网关,最终抵达Java服务的完整分布式调用链,并且确保在每个环节产生的日志都能够自动关联上这个调用链的ID。

  1. 追踪标准: 我们选择W3C Trace Context作为跨服务传递追踪上下文的标准。这是目前业界的事实标准,绝大多数APM工具和框架都原生支持,可以避免厂商锁定。它主要通过traceparenttracestate两个HTTP头来传递信息。

  2. 实现框架: OpenTelemetry (OTel) 是不二之选。它是一个CNCF的开源项目,提供了一整套标准、API和SDK,用于生成、收集和导出遥测数据(traces, metrics, logs)。其最大的优势是厂商中立和强大的社区生态,支持几乎所有主流编程语言。

  3. 追踪数据后端: 我们选择自部署Jaeger作为追踪数据的存储和可视化后台。它足够轻量,易于部署,并且与OpenTelemetry生态集成良好。在真实项目中,也可以替换为任何兼容OTLP (OpenTelemetry Protocol) 的后端,如Zipkin, SkyWalking或商业化的Datadog, New Relic。

改造第一站:Java核心服务 (JPA/Hibernate)

改造从最下游的Java服务开始。这里的关键是利用OpenTelemetry Java Agent实现无侵入式的自动埋点。这在真实项目中是一个巨大的优势,因为它意味着我们几乎不需要修改任何业务代码。

1. 引入Java Agent

我们从OTel的官方发布页面下载opentelemetry-javaagent.jar。服务的启动脚本需要做出相应调整:

# 原启动脚本
# java -jar industrial-asset-service.jar

# 改造后的启动脚本
# 通过 -javaagent 参数挂载 OTel Agent
# 通过环境变量配置服务名和数据导出端点
export OTEL_SERVICE_NAME=industrial-asset-service
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4317 # Jaeger gRPC endpoint
export OTEL_METRICS_EXPORTER=none # 本次只关注追踪,暂时禁用指标

java -javaagent:./opentelemetry-javaagent.jar \
     -jar industrial-asset-service.jar

Agent启动后,它会自动对JVM中加载的类进行字节码增强,从而拦截常见的框架调用。对于一个典型的Spring Boot + JPA应用,它能自动覆盖:

  • Servlet容器(如Tomcat)的HTTP请求入口。
  • JDBC调用,这意味着所有通过Hibernate发出的SQL查询都会被捕获。
  • 主流HTTP Client库(如OkHttp, Apache HttpClient)的调用。

2. 实现TraceID与日志的关联

自动追踪很棒,但我们的核心目标之一是关联日志。如果日志里没有trace_idspan_id,我们依然无法高效排错。幸运的是,OTel Agent能自动将当前的Trace上下文注入到MDC (Mapped Diagnostic Context) 中。我们只需要修改日志配置即可。

项目使用Logback作为日志框架,修改src/main/resources/logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <springProperty scope="context" name="springAppName" source="spring.application.name"/>

    <!-- 
      关键修改: 在日志输出格式中加入 %X{trace_id} 和 %X{span_id}
      OTel Agent会自动将追踪上下文注入到SLF4J的MDC中
    -->
    <property name="CONSOLE_LOG_PATTERN"
              value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} [trace_id=%X{trace_id:-N/A}, span_id=%X{span_id:-N/A}] : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

现在,任何由一个被追踪的HTTP请求触发的日志,都会自动携带上trace_idspan_id,例如:

2023-10-27 10:45:12.123 INFO 12345 --- [nio-8080-exec-1] c.e.i.service.AssetServiceImpl       [trace_id=a1b2c3d4e5f6, span_id=f6e5d4c3b2a1] : Fetching asset details for ID: device-001
2023-10-27 10:45:12.200 DEBUG 12345 --- [nio-8080-exec-1] org.hibernate.SQL                        [trace_id=a1b2c3d4e5f6, span_id=f6e5d4c3b2a1] : select ... from assets where id=?

仅仅通过Agent和日志配置的修改,我们的Java服务就已经具备了基本的追踪和日志关联能力。

改造第二站:Express.js 网关

Node.js环境与Java不同,没有那么“魔法”的全局Agent。我们需要通过代码来初始化OpenTelemetry SDK。

1. 引入依赖

npm install @opentelemetry/sdk-node \
            @opentelemetry/api \
            @opentelemetry/auto-instrumentations-node \
            @opentelemetry/exporter-trace-otlp-grpc \
            winston

2. 初始化Tracing SDK

在项目的入口文件(如index.jsserver.js)的最顶部,我们需要引入并配置一个tracing.js文件来初始化所有OpenTelemetry相关的设置。一个常见的错误是,在引入其他模块之后再初始化tracing,这会导致自动埋点失败。

tracing.js:

'use strict';

const process = require('process');
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { DiagConsoleLogger, DiagLogLevel, diag } = require('@opentelemetry/api');

// 用于调试OTel本身
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);

// 创建一个OTLP导出器,指向Jaeger
const traceExporter = new OTLPTraceExporter({
  url: 'grpc://localhost:4317',
});

const sdk = new NodeSDK({
  serviceName: 'express-gateway',
  traceExporter,
  instrumentations: [
    // 开启自动埋点,它会覆盖http, express, pg等常用模块
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': {
        enabled: false, // 在网关服务中,文件系统操作通常不是我们关心的,可以禁用以减少噪音
      },
    }),
  ],
});

// 优雅地关闭SDK
process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('Tracing terminated'))
    .catch((error) => console.error('Error terminating tracing', error))
    .finally(() => process.exit(0));
});

// 启动SDK
sdk.start();

console.log('OpenTelemetry Tracing initialized for express-gateway.');

然后,在主应用文件中通过node -r ./tracing.js server.js来启动服务。getNodeAutoInstrumentations会自动包裹httpexpress模块,当请求进入时,它会检查traceparent头。如果存在,就加入已有的Trace;如果不存在,就创建一个新的Trace。当网关使用axiosnode-fetch调用下游Java服务时,它也会自动注入traceparent头,从而把链路串联起来。

3. 关联日志

与Java的MDC类似,我们需要将Trace上下文信息注入到日志中。我们使用winston作为日志库。

logger.js:

const winston = require('winston');
const { trace, context } = require('@opentelemetry/api');

// 自定义一个格式化器,从当前OTel上下文中提取trace_id和span_id
const traceFormat = winston.format((info) => {
  const span = trace.getSpan(context.active());
  if (span) {
    const spanContext = span.spanContext();
    info.trace_id = spanContext.traceId;
    info.span_id = spanContext.spanId;
  }
  return info;
});

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    traceFormat(), // 应用我们的自定义格式化器
    winston.format.timestamp(),
    winston.format.json() // 在生产环境中,JSON格式的日志更易于机器解析
  ),
  transports: [
    new winston.transports.Console(),
  ],
});

module.exports = logger;

现在,在Express的路由处理器中,我们可以这样使用logger:

const express = require('express');
const axios = require('axios');
const logger = require('./logger');

const app = express();
const port = 3000;

app.get('/api/assets/:id', async (req, res) => {
  const { id } = req.params;
  logger.info(`Received request for asset ID: ${id}`);
  
  try {
    // 自动埋点会处理axios的调用,并将traceparent头传递下去
    const response = await axios.get(`http://localhost:8080/assets/${id}`);
    logger.info(`Successfully fetched data from downstream service for asset ID: ${id}`);
    res.json(response.data);
  } catch (error) {
    logger.error(`Failed to fetch data for asset ID: ${id}`, { error: error.message });
    res.status(500).send('Internal Server Error');
  }
});

app.listen(port, () => {
  logger.info(`Express gateway listening at http://localhost:${port}`);
});

产生的日志会是这样:

{
  "level": "info",
  "message": "Received request for asset ID: device-001",
  "timestamp": "2023-10-27T10:55:30.123Z",
  "trace_id": "a1b2c3d4e5f6",
  "span_id": "c3b2a1f6e5d4" 
}

可以看到,trace_id与Java服务日志中的完全一致,但span_id不同,因为它代表了调用链中的另一个环节(span)。

改造第三站:Angular 前端

前端是整个调用链的起点,也是经常被忽略的一环。在前端集成OTel,不仅可以发起追踪,还能捕获前端性能指标,如资源加载时间、用户交互延迟等。

1. 引入依赖

npm install @opentelemetry/api \
            @opentelemetry/sdk-trace-web \
            @opentelemetry/context-zone \
            @opentelemetry/instrumentation-xml-http-request \
            @opentelemetry/instrumentation-fetch \
            @opentelemetry/exporter-trace-otlp-http \
            @opentelemetry/propagator-w3c

2. 初始化Web Tracer

在Angular中,我们通常会在main.ts或者一个专门的tracing.ts模块中初始化Tracer Provider,并在AppModule中提供它。

src/app/tracing.ts:

import { ZoneContextManager } from '@opentelemetry/context-zone';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { W3CTraceContextPropagator } from '@opentelemetry/propagator-w3c';
import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { environment } from '../environments/environment';

export function initTracing(): void {
  const provider = new WebTracerProvider({
    // 你可以在这里配置资源属性,例如服务名、版本等
    // resource: new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: 'angular-frontend' })
  });
  
  // 使用OTLP HTTP导出器,注意这里的端口是OTel Collector的HTTP端口
  const exporter = new OTLPTraceExporter({
    url: `${environment.otelCollectorUrl}/v1/traces`,
  });

  provider.addSpanProcessor(new BatchSpanProcessor(exporter));

  // 为了能在Angular的异步操作中正确传递上下文,ZoneContextManager是必需的
  provider.register({
    contextManager: new ZoneContextManager(),
    propagator: new W3CTraceContextPropagator(),
  });

  // 注册自动埋点
  registerInstrumentations({
    instrumentations: [
      // Angular的HttpClient底层使用的是XMLHttpRequest
      new XMLHttpRequestInstrumentation({
        // 避免追踪发送到OTel Collector自身的请求,防止无限循环
        ignoreUrls: [environment.otelCollectorUrl],
        // 可以选择性地加入请求头到span的属性中,便于调试,但要注意隐私
        propagateTraceHeaderCorsUrls: [/.+/g], // 允许向所有URL传播追踪头
      }),
      new FetchInstrumentation(),
    ],
  });

  console.log('OpenTelemetry Tracing initialized for Angular frontend.');
}

然后在main.ts文件的开头调用initTracing()

XMLHttpRequestInstrumentation会自动拦截所有通过Angular HttpClient发出的请求,并自动添加traceparent头。这样,当Angular应用调用Express网关的API时,一条新的分布式追踪就此开始。

3. 验证与成果

经过三轮改造,我们再次复现用户报告的超时问题。这次,我们不再需要grep

  1. 在Jaeger UI中,我们看到一条持续时间很长的trace,其根span是HTTP GET,服务名为angular-frontend

  2. 点击这条trace,可以看到一个完整的火焰图:

    • angular-frontend (5000ms)
      • express-gateway (4990ms)
        • industrial-asset-service (4950ms)
          • SELECT public.assets (4800ms)
  3. 问题立刻定位:是Java后端访问数据库的SQL查询耗时过长。

  4. 我们从Jaeger中复制出这条trace的ID: a1b2c3d4e5f6

  5. 在我们的日志聚合平台(如ELK Stack或Loki)中,我们使用这个trace_id进行搜索:
    {trace_id="a1b2c3d4e5f6"}

  6. 搜索结果立即返回了来自Angular(如果有采集)、Express和Java服务的所有相关日志。我们可以清晰地看到Java服务打印出的Hibernate生成的具体SQL语句,以及它执行前后的业务日志,这为我们优化查询(比如加索引、改写SQL)提供了确切的依据。

sequenceDiagram
    participant User
    participant AngularFrontend
    participant ExpressGateway
    participant JavaService
    participant Database
    participant JaegerCollector

    User->>AngularFrontend: Click 'Refresh' button
    Note over AngularFrontend: OTel creates root span, traceId: T1
    AngularFrontend->>+ExpressGateway: GET /api/assets/device-001 (Header: traceparent=T1)
    Note over ExpressGateway: OTel continues span, logs with traceId=T1
    ExpressGateway->>+JavaService: GET /assets/device-001 (Header: traceparent=T1)
    Note over JavaService: OTel continues span, logs with traceId=T1
    JavaService->>+Database: SELECT * FROM assets WHERE id = ?
    Note over JavaService: Hibernate instrumentation creates DB span
    Database-->>-JavaService: (Slow Response)
    JavaService-->>-ExpressGateway: (Timeout or slow data)
    ExpressGateway-->>-AngularFrontend: HTTP 504 Gateway Timeout
    AngularFrontend-->>User: Show 'Timeout' error
    
    AngularFrontend-->>JaegerCollector: Export Spans
    ExpressGateway-->>JaegerCollector: Export Spans
    JavaService-->>JaegerCollector: Export Spans

局限性与后续迭代路径

当前这套方案解决了我们最核心的跨技术栈日志关联与追踪问题,但它并非银弹。

首先,自动埋点能力有其边界。对于一些非标准的异步处理、消息队列的消费或自定义的RPC框架,OpenTelemetry可能无法自动完成上下文的传递,这需要我们依据其API进行手动埋点 (tracer.startActiveSpan),这会带来一定的代码侵入性。

其次,我们目前采集并发送了100%的追踪数据。在流量较高的生产环境中,这会对网络和存储造成巨大压力。下一步的优化方向是引入采样策略。我们可以在前端或网关层面进行基于头部的采样(Head-based Sampling),或者在Collector层面部署一个尾部采样(Tail-based Sampling)处理器,只保留那些有价值的trace(例如包含错误的、耗时超过阈值的),这在保证可观测性的同时能极大降低成本。

最后,追踪(Traces)和日志(Logs)只是可观测性的两个支柱。将指标(Metrics)体系打通,实现三者之间的无缝跳转,是构建成熟可观测性平台的最终目标。例如,当我们从一条慢查询trace跳转到关联日志后,能直接链接到该时间段内数据库的CPU使用率、IOPS等关键性能指标图表,才能形成一个真正强大的、数据驱动的运维决策闭环。


  目录