构建从 Angular 到 Weaviate 的全链路可观测性与度量体系


技术痛点:向量检索的性能黑盒

我们的一个核心业务依赖于向量检索,用户在前端发起查询,后端服务进行向量化,然后请求 Weaviate 获取相似结果。最近,这个检索接口的 P99 延迟频繁告警,但排查过程异常痛苦。问题出在哪?是 Angular 应用本身的渲染卡顿,是客户端到服务端的网络延迟,是 Fastify 服务的内部逻辑处理,还是 Weaviate 的 HNSW 索引性能瓶颈?每个环节都有日志,但它们是孤立的。我们无法将前端的一次用户点击,与后端的一条慢查询日志,以及 Weaviate 的一次具体查询操作关联起来。整个请求链路就像一个性能黑盒。

初步的构想是为每个环节加上更详细的日志,但这治标不治本。我们需要的是一个统一的视图,一条贯穿始终的线索,能将用户在浏览器中的操作与后端深处的数据库查询串联起来。这个线索就是分布式追踪中的 Trace ID。目标变得清晰:构建一个从 Angular 前端到 Fastify 后端,再到 Weaviate 客户端的全链路可观测性体系,并将关键性能指标暴露给现有的 Prometheus 监控系统。

技术选型决策:为什么是 OpenTelemetry?

在选型上,我们评估了几种方案。最初考虑过各个云厂商自带的 APM 工具,但为了避免厂商锁定,并保持架构的灵活性,我们倾向于开源标准。手动在每一层传递 Trace ID 也是一个选项,但这需要大量侵入式代码,维护成本极高,而且容易出错。

最终,我们选择了 OpenTelemetry (OTel)。原因有三:

  1. 标准化与中立性: OTel 是 CNCF 的项目,提供了一套统一的 API 和 SDK,用于生成、收集和导出遥测数据(Metrics, Traces, Logs)。它不与任何后端绑定,我们可以今天将数据发送到 Jaeger 和 Prometheus,明天无缝切换到其他商业或开源方案。
  2. 自动与手动埋点的结合: OTel 提供了强大的自动埋点能力,能零侵入式地为常见的库(如 HTTP、gRPC、Fastify)添加追踪。同时,它也允许我们为核心业务逻辑(如调用 Weaviate 的部分)创建自定义的 Span,实现粗粒度与细粒度的完美结合。
  3. 生态覆盖广: OTel 的生态系统覆盖了从前端 JavaScript 到后端 Node.js 的所有技术栈,这对于我们这种全栈应用至关重要。特别是其 ZoneContextManager 能够很好地兼容 Angular 的 zone.js,解决了前端异步追踪上下文传递的难题。

我们的架构决策因此确定:使用 OpenTelemetry SDK 分别在 Angular 和 Fastify 应用中进行埋点,通过 HTTP traceparent Header 自动传递上下文。Fastify 服务将 Traces 导出到 Jaeger/Tempo,同时将自定义的业务性能指标(Metrics)通过 /metrics 端点暴露给 Prometheus。

sequenceDiagram
    participant Angular App as Angular App (Browser)
    participant Fastify API as Fastify API (Node.js)
    participant Weaviate as Weaviate
    participant Prometheus as Prometheus
    participant Jaeger as Jaeger/Tempo

    Note over Angular App, Fastify API: Both instrumented with OpenTelemetry SDK

    Angular App->>Fastify API: HTTP POST /search (with traceparent header)
    Fastify API->>Fastify API: OTel auto-instrumentation creates root server span
    Fastify API->>Fastify API: Manually create "vector-search" span
    Fastify API->>Weaviate: gRPC/HTTP query
    Weaviate-->>Fastify API: Query results
    Fastify API->>Fastify API: End "vector-search" span, record latency metric
    Fastify API-->>Angular App: HTTP 200 OK
    
    Note over Fastify API: OTel SDK exports data asynchronously
    Fastify API->>Jaeger: Export Trace Spans
    
    Prometheus->>Fastify API: Scrape /metrics endpoint
    Note left of Prometheus: Records Weaviate query latency histogram

步骤化实现:打通全链路遥测数据

1. 后端服务层:为 Fastify 和 Weaviate 植入探针

首先改造 Fastify 服务。我们需要安装 OTel 相关的 Node.js 库。

npm install @opentelemetry/sdk-node @opentelemetry/api \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-prometheus \
  @opentelemetry/exporter-trace-otlp-http \
  weaviate-ts-client

接下来,创建一个中心化的遥测服务初始化文件 telemetry.ts。这是整个后端可观测性的心脏。在真实项目中,这个文件会比示例复杂得多,需要处理优雅停机、动态配置等。

src/telemetry.ts:

import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import {
  MeterProvider,
  PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api';

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

// 1. 定义服务资源属性,这些属性会附加到所有的遥测数据上
const resource = new Resource({
  [SemanticResourceAttributes.SERVICE_NAME]: 'vector-search-api',
  [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
});

// 2. 配置 Prometheus Exporter
// 它会在指定的端口上暴露一个 /metrics 端点
const prometheusExporter = new PrometheusExporter({
  port: 9464, // Prometheus scrape port
  preventServerStart: true, // 我们希望手动控制何时启动服务
});

// 3. 配置 MeterProvider 用于处理 Metrics
// 使用 PeriodicExportingMetricReader 将指标定期推送到 Exporter
const meterProvider = new MeterProvider({ resource });
meterProvider.addMetricReader(prometheusExporter);

// 4. 配置 Trace Exporter
// 这里我们使用 OTLP 协议将追踪数据发送到类似 Jaeger/Tempo 的后端
const traceExporter = new OTLPTraceExporter({
  // URL of the OTel Collector or Jaeger/Tempo
  // 在生产环境中,这应该来自环境变量
  url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || 'http://localhost:4318/v1/traces',
});

// 5. 初始化 NodeSDK
const sdk = new NodeSDK({
  resource,
  traceExporter,
  // Node.js 中必须使用 AsyncLocalStorageContextManager
  contextManager: new AsyncLocalStorageContextManager(),
  // 这里是魔法发生的地方:自动为所有支持的库打点
  instrumentations: [getNodeAutoInstrumentations({
    // 禁用我们不需要的模块,减少性能开销
    '@opentelemetry/instrumentation-fs': {
        enabled: false,
    },
  })],
  // 将我们自定义的 MeterProvider 关联上
  metricReader: prometheusExporter,
});

// 优雅停机处理
process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('Telemetry SDK shut down successfully.'))
    .catch((error) => console.error('Error shutting down Telemetry SDK:', error))
    .finally(() => process.exit(0));
});

export const startTelemetry = async () => {
  try {
    await sdk.start();
    await prometheusExporter.startServer();
    console.log('OpenTelemetry SDK started successfully. Prometheus exporter running on port 9464.');
  } catch (error) {
    console.error('Error starting OpenTelemetry SDK:', error);
    process.exit(1);
  }
};

// 导出一个全局的 Meter,用于在业务代码中创建自定义指标
export const meter = meterProvider.getMeter('weaviate-instrumentation');

这个文件做了几件关键的事:

  • Resource定义: 标识了所有遥测数据的来源服务。
  • Prometheus Exporter: 启动了一个HTTP服务器,暴露 /metrics 接口。
  • OTLP Trace Exporter: 配置了将追踪数据发送到收集器(如 Jaeger)的端点。
  • NodeSDK: 核心,集成了自动埋点、追踪导出和上下文管理器。

现在,在应用主入口 server.ts 中启动它。

src/server.ts:

import Fastify from 'fastify';
import weaviate, { WeaviateClient } from 'weaviate-ts-client';
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import { startTelemetry, meter } from './telemetry';
import { Histogram } from '@opentelemetry/api-metrics';

// --- 启动遥测服务 ---
startTelemetry();

// --- 自定义指标 ---
// 创建一个 Histogram 来记录 Weaviate 查询延迟
// Bucket boundaries (in seconds) for latency histogram.
// [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
const weaviateQueryLatencyHistogram: Histogram = meter.createHistogram(
  'weaviate_query_latency_seconds',
  {
    description: 'Latency of Weaviate queries',
    unit: 's',
  }
);

const fastify = Fastify({ logger: true });

// --- Weaviate 客户端配置 ---
const weaviateClient: WeaviateClient = weaviate.client({
    scheme: 'http',
    host: process.env.WEAVIATE_HOST || 'localhost:8080',
});

// --- 核心业务路由 ---
fastify.post('/search', async (request, reply) => {
  // OTel 的自动埋点已经为这个路由创建了一个父 Span
  // 我们要为关键的内部操作创建子 Span
  const tracer = trace.getTracer('vector-search-tracer');
  const parentSpan = trace.getSpan(context.active());
  
  // 1. 创建手动 Span,用于追踪 Weaviate 查询
  return tracer.startActiveSpan('weaviate-vector-search', async (span) => {
    try {
      // 假设我们从请求体中获取了查询向量
      const { vector } = request.body as { vector: number[] };

      if (!vector || !Array.isArray(vector)) {
        span.setStatus({ code: SpanStatusCode.ERROR, message: 'Invalid vector in request body' });
        span.end();
        return reply.status(400).send({ error: 'Invalid vector provided' });
      }

      // 附加有意义的属性到 Span 上,便于后续排查
      span.setAttribute('weaviate.collection', 'MyDocuments');
      span.setAttribute('weaviate.query.vector_dimensions', vector.length);

      const startTime = performance.now();

      const response = await weaviateClient.graphql
        .get()
        .withClassName('MyDocuments')
        .withNearVector({ vector })
        .withLimit(5)
        .withFields('_additional { id certainty distance }')
        .do();

      const endTime = performance.now();
      const latencySeconds = (endTime - startTime) / 1000;
      
      // 2. 记录自定义指标
      weaviateQueryLatencyHistogram.record(latencySeconds, {
        collection: 'MyDocuments',
      });

      // 再次附加结果信息到 Span
      const resultsCount = response.data.Get.MyDocuments.length;
      span.setAttribute('weaviate.results.count', resultsCount);
      span.setStatus({ code: SpanStatusCode.OK });

      reply.send(response.data);

    } catch (error: any) {
      fastify.log.error(error, 'Weaviate search failed');
      // 3. 在 Span 中记录错误
      span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
      span.recordException(error);
      reply.status(500).send({ error: 'Internal Server Error' });
    } finally {
      // 确保 Span 总是被关闭
      span.end();
    }
  });
});


const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

这里的关键点:

  • 手动创建 Span: 我们使用 tracer.startActiveSpan 包裹了对 Weaviate 的调用。这使得在追踪视图中,我们可以清晰地看到 weaviate-vector-search 这个操作占用了多少时间。
  • 丰富的属性: 我们在 Span 上附加了集合名称、向量维度、返回结果数等上下文信息。当问题发生时,这些信息是定位问题的金钥匙。
  • 错误记录: 当查询失败时,使用 span.setStatusspan.recordException 记录详细的错误信息,而不是让它无声地失败。
  • 自定义指标: weaviate_query_latency_seconds 这个 Histogram 指标是为 Prometheus 准备的。它让我们能够计算 Weaviate 查询的平均延迟、P95、P99 等关键 SLI,并设置告警。

2. 前端应用层:将 Angular 用户操作与后端请求关联

现在轮到前端。在 Angular 中集成 OTel 稍微复杂一些,因为它需要处理 zone.js 的上下文。

npm install @opentelemetry/sdk-trace-web @opentelemetry/context-zone \
  @opentelemetry/instrumentation-fetch @opentelemetry/exporter-otlp-http \
  @opentelemetry/resource-web

我们在 Angular 项目中创建一个 tracing.service.ts 来封装所有 OTel 的逻辑。

src/app/tracing.service.ts:

import { Injectable, NgZone } from '@angular/core';
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-http';
import { Resource } from '@opentelemetry/resource-web';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';

@Injectable({
  providedIn: 'root',
})
export class TracingService {
  private tracerProvider: WebTracerProvider | null = null;

  constructor(private ngZone: NgZone) {}

  public initialize(): void {
    // 防止在服务端渲染(SSR)或多次初始化时重复执行
    if (this.tracerProvider) {
      return;
    }

    // 1. 定义前端应用的服务资源
    const resource = new Resource({
      [SemanticResourceAttributes.SERVICE_NAME]: 'vector-search-frontend',
      [SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
    });

    // 2. 创建 WebTracerProvider
    const provider = new WebTracerProvider({
      resource,
    });
    
    // 3. 配置 OTLP Exporter
    const exporter = new OTLPTraceExporter({
      url: 'http://localhost:4318/v1/traces', // 指向 OTel Collector
    });
    
    // 4. 使用 SimpleSpanProcessor,因为在浏览器中我们希望尽快发送追踪数据
    provider.addSpanProcessor(new SimpleSpanProcessor(exporter));

    // 5. 关键:使用 ZoneContextManager 来兼容 Angular 的 zone.js
    // 它确保了在异步操作(如 setTimeout, Promise, HTTP请求)中追踪上下文不会丢失
    provider.register({
      contextManager: new ZoneContextManager(this.ngZone),
    });

    // 6. 注册自动埋点工具
    // FetchInstrumentation 会自动拦截 fetch 请求
    // 并在请求头中注入 'traceparent',将前端 Span 和后端 Span 关联起来
    registerInstrumentations({
      tracerProvider: provider,
      instrumentations: [
        new FetchInstrumentation({
          // 可以配置忽略一些不关心的请求,比如对静态资源的请求
          ignoreUrls: [/.*\.ico/],
          // 可以在请求发送前添加额外属性
          propagateTraceHeaderCorsUrls: [
            /http:\/\/localhost:3000\/.*/, // 必须包含后端API的URL,以允许跨域传递traceparent头
          ],
        }),
      ],
    });
    
    this.tracerProvider = provider;
    console.log('OpenTelemetry Web SDK initialized.');
  }
}

然后在 app.module.ts 或主组件 app.component.ts 中尽早初始化这个服务。

src/app/app.component.ts:

import { Component, OnInit } from '@angular/core';
import { TracingService } from './tracing.service';
import { HttpClient } from '@angular/common/http';
import { trace } from '@opentelemetry/api';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  
  constructor(
    private tracingService: TracingService, 
    private http: HttpClient
  ) {
    // 尽早初始化
    this.tracingService.initialize();
  }

  ngOnInit() { }

  performSearch() {
    // 1. 获取一个 tracer 实例
    const tracer = trace.getTracer('angular-app-tracer');
    
    // 2. 创建一个代表用户意图的父 Span
    tracer.startActiveSpan('user-clicks-search-button', (span) => {
      console.log('Starting search process from UI...');
      span.setAttribute('component', 'AppComponent');
      span.setAttribute('user.action', 'click');

      // 模拟生成一个查询向量
      const randomVector = Array.from({ length: 1536 }, () => Math.random());

      // HttpClient 的请求会被 FetchInstrumentation 自动拦截
      // 这次请求会自动携带 `traceparent` 头
      this.http.post('http://localhost:3000/search', { vector: randomVector })
        .subscribe({
          next: (response) => {
            console.log('Search successful', response);
            span.addEvent('search_success');
          },
          error: (error) => {
            console.error('Search failed', error);
            span.recordException(error);
            span.setStatus({ code: 2, message: error.message }); // 2 is ERROR code
          },
          complete: () => {
            // 3. 当整个流程结束时,关闭 Span
            span.end();
            console.log('Search process span ended.');
          }
        });
    });
  }
}

前端集成的核心在于 ZoneContextManagerFetchInstrumentation。前者保证了追踪上下文在 Angular 的异步世界里正确传递,后者则像一座桥梁,自动将前端的 Trace ID 注入到对后端的 API 请求中,实现了链路的自动串联。

3. 验证与观测:在 Prometheus 和 Jaeger 中看到成果

万事俱备,现在是验证成果的时候。

  1. 启动所有服务: 启动 Weaviate, Jaeger/Tempo, Prometheus, Fastify API, 以及 Angular 应用。
  2. 配置 Prometheus: 确保 Prometheus 的配置文件 prometheus.yml 包含了对 Fastify 服务 /metrics 端点的抓取配置。
# prometheus.yml
scrape_configs:
  - job_name: 'vector-search-api'
    static_configs:
      - targets: ['host.docker.internal:9464'] # 或者你的 Fastify 服务实际地址
  1. 触发操作: 在 Angular 界面点击搜索按钮。
  2. 观察结果:
    • 在 Jaeger/Tempo UI 中: 你会看到一条完整的链路。根 Span 是 user-clicks-search-button,它有一个子 Span 是 Fastify 的 /search 路由处理,而这个子 Span 又有一个更深的子 Span weaviate-vector-search。每个 Span 的耗时、属性、错误都一目了然。现在,如果查询慢,我们可以精确地知道是慢在了 weaviate-vector-search 还是 Fastify 的其他逻辑上。
    • 在 Prometheus/Grafana 中: 我们可以使用 PromQL 查询我们自定义的指标:
      # 计算过去5分钟内 Weaviate 查询的 P99 延迟
      histogram_quantile(0.99, sum(rate(weaviate_query_latency_seconds_bucket[5m])) by (le, collection))
      
      # 计算查询的 QPS
      sum(rate(weaviate_query_latency_seconds_count[5m])) by (collection)
      基于这些查询,我们可以构建仪表盘,设置告警,真正做到基于数据的性能监控和容量规划。

当前方案的局限性与未来迭代

这套基于 OpenTelemetry 的体系极大地提升了我们对向量检索链路的可见性,但它并非银弹。当前的实现依然存在一些局限和值得优化的地方。

首先,我们的埋点仅限于应用层。对于 Weaviate 自身的性能,比如它内部的索引查询、对象获取、压缩等阶段的耗时,我们仍然是黑盒。一个更理想的方案是 Weaviate 自身也能支持 OTel,导出其内部操作的 Span。在此之前,我们只能依赖其暴露的 Prometheus 指标,并通过 Trace ID 与应用层 Span 进行手动关联分析。

其次,对于前端而言,我们目前只追踪了 API 请求。用户的真实体验还包括页面加载时间、资源渲染耗时、交互卡顿等。后续可以集成 OpenTelemetry 的 Real User Monitoring (RUM) 相关工具,捕获 Core Web Vitals 等指标,从而获得更完整的用户体验画像。

最后,随着系统复杂度的增加,全量采集 Trace 可能会带来巨大的存储成本和性能开销。下一步需要研究和实施更智能的采样策略,例如基于尾部的采样,只保留那些包含错误或者延迟异常的链路,在保证问题可追溯性的前提下,有效控制数据量。


  目录