实现从 JavaScript 到 CockroachDB 的全链路追踪:SkyWalking 与 OpenTelemetry 在异构系统中的深度集成


一个用户反馈操作缓慢的工单,最终可能会演变成一场跨越前端、网关、后端服务乃至数据库的“寻凶”之旅。在我们的系统中,这个典型的请求链路是:用户浏览器(JavaScript) -> Node.js BFF (Backend for Frontend) -> Java 核心服务 -> CockroachDB 分布式数据库。当延迟发生在其中任何一环,或者更糟,是多环累加时,定位问题的成本会呈指数级增长。传统的日志分析在这种场景下显得苍白无力,它缺少将离散事件串联成完整调用链的能力。

我们的目标很明确:构建一个统一的、端到端的全链路追踪系统。技术选型围绕着 Apache SkyWalking 展开,它对 Java 生态的无侵入式探针是其巨大优势。然而,要将 JavaScript (浏览器端和Node.js) 与分布式的 CockroachDB 也无缝地纳入这个体系,挑战便接踵而至。这不仅仅是安装几个探针那么简单,而是要解决跨语言、跨协议的上下文传递问题。

第一站:稳固后端 Java 服务与数据库的可观测性

这是整个追踪体系的基石。Java 微服务是我们的核心业务逻辑承载者,它直接与 CockroachDB 交互。SkyWalking 的 Java Agent 在这里表现出色。

1. 核心 Java 服务的探针集成

假设我们有一个基于 Spring Boot 的 product-service。要将其接入 SkyWalking,无需修改任何一行代码。关键在于启动参数和配置。

首先,准备 SkyWalking Java Agent。在生产环境中,我们通常会将其作为基础镜像的一部分或者通过 init container 注入。

agent 目录结构:

skywalking-agent/
├── activations
├── config
│   └── agent.config
├── logs
├── plugins
│   ├── apm-cockroachdb-8.x-plugin.jar
│   └── ...
└── skywalking-agent.jar

我们的 Dockerfile 会包含类似这样的指令:

# Dockerfile for product-service
FROM openjdk:17-slim

# ... application build steps ...

# Add SkyWalking Agent
ARG SKYWALKING_AGENT_VERSION=9.1.0
ADD https://archive.apache.org/dist/skywalking/java-agent/${SKYWALKING_AGENT_VERSION}/apache-skywalking-java-agent-${SKYWALKING_AGENT_VERSION}.tgz /opt/
RUN tar -zxf /opt/apache-skywalking-java-agent-*.tgz -C /opt/ && \
    mv /opt/apache-skywalking-java-agent* /opt/skywalking-agent

WORKDIR /app
COPY target/product-service.jar .

# Agent configuration will be passed via environment variables
# SW_AGENT_NAME, SW_AGENT_COLLECTOR_BACKEND_SERVICES etc.
ENTRYPOINT ["java", "-javaagent:/opt/skywalking-agent/skywalking-agent.jar", "-jar", "product-service.jar"]

agent.config 文件是微调的关键,但在现代部署中,我们更倾向于使用环境变量进行覆盖,这更符合云原生实践。例如,在 Kubernetes 的 Deployment YAML 中:

# kubernetes-deployment.yaml snippet
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
      - name: product-service
        image: my-repo/product-service:latest
        env:
        - name: SW_AGENT_NAME
          value: "product-service"
        - name: SW_AGENT_COLLECTOR_BACKEND_SERVICES
          value: "skywalking-oap.skywalking.svc:11800"
        - name: SW_PLUGIN_POSTGRESQL_TRACE_SQL_PARAMETERS
          value: "true" # CockroachDB uses PostgreSQL protocol

启动后,这个 Java 服务的所有 Spring MVC 端点、RestTemplate 调用以及 JDBC 操作都会被自动拦截并生成追踪数据。

2. 追踪分布式 SQL:CockroachDB 的挑战

CockroachDB 是一个分布式 SQL 数据库,兼容 PostgreSQL 协议。SkyWalking 提供了针对 PostgreSQL JDBC 驱动的插件。当 product-service 通过 JDBC 连接 CockroachDB 时,这个插件会自动创建数据库访问的 Span。

一个常见的坑在于,默认配置下,为了安全和性能,插件不会记录 SQL 语句的具体参数。但在调试环境中,开启参数记录至关重要。这就是上面配置中 SW_PLUGIN_POSTGRESQL_TRACE_SQL_PARAMETERS: "true" 的作用。

// A typical repository method in product-service
@Repository
public class ProductRepository {

    private final JdbcTemplate jdbcTemplate;

    // Constructor injection
    public ProductRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Optional<Product> findById(String id) {
        // This query will be automatically traced by the SkyWalking agent.
        // The span will include the DB type, instance, statement, and parameters.
        try {
            String sql = "SELECT id, name, price FROM products WHERE id = ?";
            return Optional.ofNullable(
                jdbcTemplate.queryForObject(sql, new Object[]{id}, (rs, rowNum) ->
                    new Product(rs.getString("id"), rs.getString("name"), rs.getBigDecimal("price"))
                )
            );
        } catch (EmptyResultDataAccessException e) {
            // This is an expected case, not an error for tracing purposes
            return Optional.empty();
        }
    }
}

至此,从 product-service 的 RESTful 入口到 CockroachDB 的调用链路已经打通。在 SkyWalking UI 上,我们可以清晰地看到类似 ProductController.getProduct -> ProductService.getProduct -> CockroachDB/PostgreSQL: SELECT 的调用链。

第二站:攻克 Node.js 网关与 OpenTelemetry 集成

我们的 Node.js BFF 层使用 Express.js 构建,它负责聚合来自后端 Java 服务的数据,并为前端提供更友好的 API。这里没有像 Java Agent 那样便捷的无侵入式探针。我们必须拥抱 OpenTelemetry,并将其作为桥梁,连接 JavaScript 世界和 SkyWalking 后端。

1. 初始化 OpenTelemetry SDK

我们需要为 Node.js 应用创建一个独立的追踪初始化文件。这个文件必须在应用主逻辑加载之前被 require

tracer.js:

'use strict';

const process = require('process');
const opentelemetry = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');

// Ensure all environment variables are properly configured before this runs.
// OTEL_SERVICE_NAME: 'bff-gateway'
// OTEL_EXPORTER_OTLP_ENDPOINT: 'http://skywalking-oap.skywalking.svc:11800' (note: OAP gRPC endpoint)

if (!process.env.OTEL_SERVICE_NAME) {
    console.error("OTEL_SERVICE_NAME environment variable is not set. Tracing disabled.");
    return;
}

// 1. Configure the OTLP Exporter to send data to SkyWalking OAP
// SkyWalking's OAP receiver for OTLP is at port 11800 (for gRPC).
// Note: SkyWalking OAP's OTLP receiver expects gRPC. Don't use the HTTP exporter unless OAP is configured for it.
const traceExporter = new OTLPTraceExporter({
    // url: is optional and can be set via OTEL_EXPORTER_OTLP_ENDPOINT env var.
});

// 2. Configure the SDK
const sdk = new opentelemetry.NodeSDK({
    resource: new Resource({
        [SemanticResourceAttributes.SERVICE_NAME]: process.env.OTEL_SERVICE_NAME,
    }),
    traceExporter,
    // 3. Enable automatic instrumentations for popular libraries
    instrumentations: [getNodeAutoInstrumentations({
        // Disable instrumentations that are not needed to reduce overhead
        '@opentelemetry/instrumentation-fs': {
            enabled: false,
        },
    })],
});

// 4. Start the SDK and gracefully shut it down on process exit
try {
    sdk.start();
    console.log('OpenTelemetry tracing initialized successfully.');

    process.on('SIGTERM', () => {
        sdk.shutdown()
            .then(() => console.log('Tracing terminated'))
            .catch((error) => console.error('Error terminating tracing', error))
            .finally(() => process.exit(0));
    });
} catch (error) {
    console.error('Error initializing OpenTelemetry tracing', error);
}

module.exports = sdk;

package.json 的依赖项:

{
  "dependencies": {
    "@opentelemetry/api": "^1.7.0",
    "@opentelemetry/auto-instrumentations-node": "^0.40.0",
    "@opentelemetry/exporter-trace-otlp-grpc": "^0.48.0",
    "@opentelemetry/resources": "^1.21.0",
    "@opentelemetry/sdk-node": "^0.48.0",
    "@opentelemetry/semantic-conventions": "^1.21.0",
    "express": "^4.18.2"
    // ... other dependencies
  }
}

启动应用时,使用 -r 参数预加载追踪器:

node -r ./tracer.js server.js

2. 上下文传递的关键

当 Node.js BFF 收到来自前端的请求,并随后调用后端的 product-service 时,最关键的一步是传递追踪上下文。幸运的是,@opentelemetry/instrumentation-http 自动完成了这项工作。它会:

  1. 解析入口请求头: 检查是否存在 traceparent (W3C Trace Context) 或 sw8 (SkyWalking’s format) 头。
  2. 创建新的 Span: 为 Express.js 的路由处理器创建一个新的 Span,并将其作为入口请求 Span 的子 Span。
  3. 注入出口请求头: 当 BFF 使用 axiosnode-fetch 调用 product-service 时,它会自动将 traceparent 和/或 sw8 头注入到这个出站请求中。

SkyWalking Java Agent 默认同时支持 W3C 和 sw8 格式。这意味着当 product-service 收到来自 BFF 的请求时,它能正确识别并续接这条调用链。

// server.js in BFF
const express = require('express');
const axios = require('axios'); // axios will be auto-instrumented
const app = express();
const port = 3000;

const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://product-service.default.svc:8080';

app.get('/products/:id', async (req, res) => {
    // The OpenTelemetry instrumentation for http will automatically create a span for this handler.
    try {
        // When this axios call is made, OTel injects trace context headers.
        const response = await axios.get(`${PRODUCT_SERVICE_URL}/products/${req.params.id}`);
        res.json(response.data);
    } catch (error) {
        // Error handling is crucial for accurate tracing
        const span = opentelemetry.trace.getActiveSpan();
        if (span) {
            span.recordException(error);
            span.setStatus({ code: opentelemetry.api.SpanStatusCode.ERROR, message: error.message });
        }
        res.status(error.response?.status || 500).send('Error fetching product data');
    }
});

app.listen(port, () => {
    console.log(`BFF gateway listening on port ${port}`);
});

现在,请求链路已经延长为:BFF Gateway -> product-service -> CockroachDB

第三站:打通最后一公里,浏览器端追踪

最后,我们需要从用户操作的源头——浏览器——开始追踪。这同样需要 OpenTelemetry。

1. Web 端 OpenTelemetry SDK 配置

我们将创建一个简单的 JS 文件来初始化 Web 追踪器,并通过构建工具(如 Webpack 或 Vite)将其打包到应用中。

web-tracer.js:

import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const VITE_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = import.meta.env.VITE_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;

if (!VITE_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) {
    console.error("OTLP endpoint for traces is not configured. Tracing disabled.");
    return;
}

const resource = new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'frontend-app',
    [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
});

const provider = new WebTracerProvider({
    resource: resource,
});

// Use the OTLP HTTP exporter for web
const exporter = new OTLPTraceExporter({
    url: VITE_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, // e.g., http://skywalking-oap:12800/v1/traces
});

provider.addSpanProcessor(new BatchSpanProcessor(exporter, {
    // The maximum queue size. After the size is reached spans are dropped.
    maxQueueSize: 100,
    // The maximum batch size of spans for a single export.
    maxExportBatchSize: 10,
    // The interval between two consecutive exports
    scheduledDelayMillis: 500,
}));

// ZoneContextManager is required for web applications to correctly propagate context
provider.register({
    contextManager: new ZoneContextManager(),
});

// Registering instrumentations
registerInstrumentations({
    instrumentations: [
        getWebAutoInstrumentations({
            // Only instrument fetch and XHR for this example
            '@opentelemetry/instrumentation-xml-http-request': {
                enabled: true,
            },
            '@opentelemetry/instrumentation-fetch': {
                enabled: true,
                // Function to add custom headers to traced requests
                propagateTraceHeaderCorsUrls: [
                    /http:\/\/localhost:3000/i, // Match our BFF gateway URL
                    /https:\/\/api\.yourdomain\.com/i,
                ],
            },
            '@opentelemetry/instrumentation-document-load': {
                enabled: true,
            },
        }),
    ],
});

注意,Web 端使用 OTLP over HTTP/JSON,其 OAP 接收端口通常是 12800,与 gRPC 的 11800 不同。这需要在 SkyWalking OAP 的 application.yml 中确认 receiver-otlp 的 HTTP 端口是开启的。

2. 触发追踪

当 Web 应用发起一个 API 请求时,例如获取产品详情:

// In a React component or vanilla JS
async function fetchProduct(productId) {
    try {
        // This fetch call is now automatically instrumented by OpenTelemetry.
        // It will inject the `traceparent` header.
        const response = await fetch(`http://localhost:3000/products/${productId}`);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error("Failed to fetch product:", error);
        // We could also manually create a span here to record the error if needed
    }
}

@opentelemetry/instrumentation-fetch 会自动为这个 fetch 调用创建 Span,并注入 traceparent 请求头。BFF 网关的 OpenTelemetry SDK 接收到这个头,续接链路,然后当它调用后端 Java 服务时,再继续传递下去。

最终的完整链路

通过以上三个步骤,我们构建了一条完整的、跨越技术栈的调用链。

sequenceDiagram
    participant Browser as Browser (JavaScript)
    participant BFF as Node.js BFF Gateway
    participant ProductSvc as Java Product Service
    participant Cockroach as CockroachDB Cluster

    Browser->>+BFF: GET /products/123 (traceparent header injected by OTel Web)
    Note over BFF: OTel Node SDK receives traceparent, starts new Span.
    BFF->>+ProductSvc: GET /products/123 (traceparent forwarded by OTel Node)
    Note over ProductSvc: SkyWalking Agent receives traceparent, continues Trace.
    ProductSvc->>+Cockroach: SELECT ... WHERE id='123'
    Note over ProductSvc: SkyWalking JDBC plugin creates DB Span.
    Cockroach-->>-ProductSvc: Product Data
    ProductSvc-->>-BFF: {id: "123", ...}
    BFF-->>-Browser: {id: "123", ...}

在 SkyWalking UI 中,一个用户点击操作现在会呈现为一个完整的、树状的 Trace 视图,清晰地展示了每一阶段的耗时:

  • frontend-app: /products/123 (Document Load, Fetch)
    • bff-gateway: GET /products/:id
      • product-service: ProductController.getProduct
        • CockroachDB/PostgreSQL: SELECT FROM products

每一层的耗时、状态码、SQL 语句等元数据都一目了然,问题定位从数小时的日志排查缩短到分钟级的链路分析。

遗留问题与未来迭代路径

这套方案虽然打通了全链路,但并非完美。在生产环境中,依然存在一些需要权衡和优化的点。

首先,采样率。在流量巨大的系统中,100% 采样会给 OAP 服务器和存储带来巨大压力。必须实施动态采样策略,例如基于请求路径的固定采样,或者更智能的尾部采样,优先保留包含错误或高延迟的链路。

其次,业务上下文的注入。当前链路仅包含技术层面的信息。更有价值的追踪应包含业务标识,如 userIdtenantId。这需要在代码中通过 OpenTelemetry API (span.setAttribute('user.id', userId)) 手动添加,如何在不污染业务逻辑的前提下优雅地实现这一点,是一个需要精心设计的问题。

最后,对 CockroachDB 的追踪仍然停留在客户端层面。我们能看到 JDBC 驱动执行查询的耗时,但无法洞察查询在 CockroachDB 集群内部的执行计划、节点间路由和延迟。要实现更深层次的数据库可观测性,需要依赖 CockroachDB 自身暴露的监控指标和 tracing 支持,并将其与 SkyWalking 的数据进行关联分析,这是可观测性体系建设的下一步。

```


  目录