CI流水线里一个端到端测试失败了,日志只留下一行冰冷的 500 Internal Server Error。前端开发者认为是后端的锅,后端开发者检查日志发现并无可疑之处,而AI模型的工程师则表示模型服务的心跳正常。皮球被踢了一圈,数小时后问题才被定位到一个数据预处理的边界情况。这种场景在真实项目中屡见不鮮,根源在于开发、测试与生产环境之间存在巨大的可观测性鸿沟。我们的目标是彻底打通这条链路,让一次Cypress测试的失败,能直接关联到CI Job的执行上下文、Svelte前端的具体请求,以及后端AI服务的内部调用堆栈,形成一个完整的分布式追踪链。
这个方案的核心是将W3C Trace Context规范贯穿始终,从CI系统的触发点开始,注入到Cypress测试执行器,再由Cypress传递给Svelte应用,最终抵达后端服务。
技术痛点与架构构想
在典型的微服务架构中,我们已经习惯于使用Jaeger或类似工具来追踪生产环境中的服务间调用。然而,可观测性的价值不应局限于生产环境。开发与测试阶段的效率,直接决定了交付速度和质量。
痛点:
- CI/CD 黑盒: CI/CD流水线本身是一个复杂的分布式系统(Runner、Registry、部署工具),其内部状态和性能往往被忽略。一个构建任务的缓慢,其原因可能在代码拉取、依赖安装、测试执行或镜像推送等任何一个环节。
- E2E 测试与后端脱节: Cypress测试在浏览器环境中模拟用户操作,它能验证UI行为和API契约,但当API返回错误时,它对后端发生的事情一无所知。开发者需要在多个系统中手动关联时间戳和请求ID,效率低下且容易出错。
- AI 模型的不可解释性: AI服务通常是计算密集型和数据依赖型的。一次推理请求的延迟或失败,可能源于输入数据、模型加载、特征工程或上游数据源。如果没有细粒度的追踪,这些问题极难复现和诊断。
架构构想:
我们将构建一个统一的可观测性平面,覆盖从代码提交到应用响应的全过程。
sequenceDiagram
participant GitLab Runner as CI Runner
participant Cypress as E2E Test Runner
participant Svelte App as Browser Frontend
participant FastAPI as AI Backend
participant Jaeger as Collector/UI
CI Runner->>+Cypress: 启动测试 (注入Trace Context)
Cypress->>+Svelte App: cy.visit() (传递Trace Context)
Svelte App->>+FastAPI: fetch('/api/predict') (携带Trace Headers)
FastAPI->>FastAPI: 执行数据预处理
FastAPI->>FastAPI: 调用模型推理
FastAPI-->>-Svelte App: 返回结果
Svelte App-->>-Cypress: 断言结果
Cypress-->>-CI Runner: 测试完成
CI Runner->>Jaeger: 上报CI Job Span
Cypress->>Jaeger: 上报Test Suite/Case Spans
Svelte App->>Jaeger: 上报Frontend Spans
FastAPI->>Jaeger: 上报Backend Spans
这个架构的关键在于Trace Context的无缝传递。我们将使用OpenTelemetry作为标准化的实现,因为它提供了跨语言、跨平台的统一API。
第一步:环境搭建与后端服务埋点
我们从最底层的后端服务开始。这里用一个简单的FastAPI应用模拟AI推理服务。同时,使用docker-compose快速启动Jaeger和我们的应用。
docker-compose.yml
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:1.41
container_name: jaeger
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC receiver
- "4318:4318" # OTLP HTTP receiver
environment:
- COLLECTOR_OTLP_ENABLED=true
ai_service:
build: .
container_name: ai_service
ports:
- "8000:8000"
environment:
# 将追踪数据发送到Jaeger OTLP gRPC接收器
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
- OTEL_SERVICE_NAME=ai-prediction-service
- OTEL_RESOURCE_ATTRIBUTES=service.version=1.0.0
depends_on:
- jaeger
后端AI服务 (main.py)
这是一个用FastAPI构建的简单服务,它接收文本输入,模拟进行一些处理,然后返回结果。我们将使用opentelemetry-instrumentation-fastapi自动对其进行埋点。
requirements.txt
fastapi
uvicorn
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-requests
tracing.py
这个文件负责初始化OpenTelemetry SDK。在真实项目中,配置应该更加复杂,例如包含采样策略。
# tracing.py
import logging
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
def setup_tracing():
"""
初始化 OpenTelemetry Tracer.
配置项通过环境变量读取 (OTEL_*), 例如:
- OTEL_EXPORTER_OTLP_ENDPOINT
- OTEL_SERVICE_NAME
"""
try:
# 创建一个资源,用于标识所有从这个服务发出的spans
# OTEL_RESOURCE_ATTRIBUTES 和 OTEL_SERVICE_NAME 会被自动使用
resource = Resource.create()
# 设置TracerProvider,这是所有Tracer的来源
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
# 配置OTLP Exporter,将spans发送到Jaeger
# 默认使用gRPC协议,端点从环境变量OTEL_EXPORTER_OTLP_ENDPOINT读取
otlp_exporter = OTLPSpanExporter()
# 使用BatchSpanProcessor批量处理和发送spans,这在生产环境中性能更好
span_processor = BatchSpanProcessor(otlp_exporter)
provider.add_span_processor(span_processor)
logging.info("OpenTelemetry Tracing initialized successfully.")
except Exception as e:
# 在真实项目中,这里应该有更健壮的错误处理
logging.error(f"Failed to initialize OpenTelemetry Tracing: {e}", exc_info=True)
main.py
# main.py
import time
import random
import logging
from fastapi import FastAPI, Request
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from tracing import setup_tracing
# 配置基础日志
logging.basicConfig(level=logging.INFO)
# 在应用启动前初始化追踪
setup_tracing()
app = FastAPI()
# 获取一个tracer实例,用于手动创建spans
tracer = trace.get_tracer(__name__)
# 模拟一个耗时的AI模型推理过程
def perform_inference(text: str):
"""
这个函数模拟一个复杂的、多阶段的AI处理流程。
我们在这里手动创建子Spans,以获得更精细的可见性。
"""
with tracer.start_as_current_span("ai_model.inference") as inference_span:
# 为span添加有用的属性 (attributes), 便于后续查询和分析
inference_span.set_attribute("model.name", "transformer-v3")
inference_span.set_attribute("input.length", len(text))
# 阶段1: 数据预处理
with tracer.start_as_current_span("data_preprocessing") as preproc_span:
# 模拟IO或CPU密集型任务
time.sleep(random.uniform(0.01, 0.05))
if "error" in text:
# 记录一个异常事件,这会在Jaeger UI中清晰地展示
preproc_span.record_exception(ValueError("Invalid characters in input"))
preproc_span.set_status(trace.Status(trace.StatusCode.ERROR, "Preprocessing failed"))
raise ValueError("Preprocessing failed due to invalid input")
preproc_span.set_attribute("text.cleaned", True)
# 阶段2: 模型核心计算
with tracer.start_as_current_span("core_computation"):
time.sleep(random.uniform(0.05, 0.15))
inference_span.set_attribute("result.confidence", random.random())
return f"Processed text: {text}"
@app.post("/api/predict")
async def predict(request: Request):
"""
API端点,接收预测请求。
FastAPIInstrumentor 会自动为这个请求创建一个根Span。
"""
data = await request.json()
input_text = data.get("text")
if not input_text:
return {"error": "Text input is required"}, 400
try:
# 调用我们的核心业务逻辑
result = perform_inference(input_text)
return {"prediction": result}
except ValueError as e:
# 在捕获到特定业务异常时,返回一个有意义的错误码
# FastAPIInstrumentor 会自动将500错误标记为Span的ERROR状态
return {"error": str(e)}, 500
# 使用instrumentor包装FastAPI应用
# 这会自动追踪所有进出的请求
FastAPIInstrumentor.instrument_app(app)
现在,运行docker-compose up --build,访问http://localhost:16686,就能看到Jaeger UI。发送一个POST请求到http://localhost:8000/api/predict,你将在Jaeger中看到完整的后端调用链,包括data_preprocessing和core_computation等子span。
第二步:为Svelte前端注入可观测性
前端是追踪的起点。我们需要捕获页面加载、路由切换以及API调用。这里我们使用SvelteKit框架。
安装依赖:
npm install @opentelemetry/api @opentelemetry/sdk-trace-web @opentelemetry/exporter-trace-json @opentelemetry/instrumentation-fetch @opentelemetry/context-zone @opentelemetry/propagator-w3c
注意:在浏览器中,我们通常不直接将追踪数据发送到OTLP/gRPC端点,因为这涉及到CORS和复杂的协议处理。一个常见的模式是发送到后端的某个代理端点,或者直接发送到支持OTLP/HTTP的Collector。为了简化,这里使用exporter-trace-json,它会把追踪信息打印到控制台,在真实项目中应替换为OTLPHttpExporter。
src/lib/tracing.ts
这是前端的追踪初始化逻辑。它需要在应用的入口处(客户端)被调用。
// src/lib/tracing.ts
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { W3CTraceContextPropagator } from '@opentelemetry/propagator-w3c';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { WebTracerProvider, ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
// 在SvelteKit中,我们需要确保这段代码只在浏览器端执行
import { browser } from '$app/environment';
let provider: WebTracerProvider | null = null;
export function initializeTracing() {
// 防止在SSR或多次导航中重复初始化
if (!browser || provider) {
return;
}
provider = new WebTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'svelte-frontend-app',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
}),
});
// 为了调试,我们使用ConsoleSpanExporter。
// 在生产中,应替换为 OTLPHttpExporter,并指向Collector。
// const exporter = new OTLPHttpExporter({ url: 'http://jaeger:4318/v1/traces' });
const exporter = new ConsoleSpanExporter();
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
// 使用ZoneContextManager来自动在异步任务间传递上下文
// 这是在浏览器中确保追踪连续性的关键
provider.register({
contextManager: new ZoneContextManager(),
propagator: new W3CTraceContextPropagator(), // 使用标准的W3C Trace Context
});
// 注册自动化埋点
registerInstrumentations({
instrumentations: [
// 自动追踪所有由 `fetch` 发出的请求
new FetchInstrumentation({
// 我们可以配置哪些请求需要被追踪
ignoreUrls: [/.*\/sockjs-node\/.*/],
// 这是一个非常关键的配置:确保W3C traceparent头被附加到出站请求上
propagateTraceHeaderCorsUrls: [
/http:\/\/localhost:8000\/.*/, // 允许向我们的后端传播上下文
],
// 可以在这里丰富span的属性
applyCustomAttributesOnSpan: (span, request) => {
span.setAttribute('http.request.headers', JSON.stringify(request.headers));
}
}),
],
});
console.log("Frontend tracing initialized.");
}
在SvelteKit中激活追踪:
在主布局文件src/routes/+layout.svelte中调用初始化函数。
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { initializeTracing } from '$lib/tracing';
// onMount确保代码只在客户端执行
onMount(() => {
initializeTracing();
});
</script>
<slot />
现在,Svelte应用中的fetch调用会自动携带traceparent头。当我们的前端调用FastAPI后端时,后端的FastAPIInstrumentor会自动解析这个头,并将后端的Span关联为前端Span的子Span,形成一条完整的调用链。
第三步:让Cypress成为追踪链的关键一环
这是整个方案中最具挑战性也最有价值的一步。我们需要让Cypress测试本身成为一个可追踪的实体,并将它的上下文传递给它所测试的应用。
策略:
- 在Cypress测试开始时,创建一个顶级的
Test SuiteSpan。 - 为每个
it测试用例创建一个子Span。 - 重写
cy.visit和cy.request等命令,让它们在发出请求时,能够从当前的Span上下文中提取traceparent并注入到请求头中。
cypress/support/e2e.ts
我们需要在这里初始化一个用于Cypress自身的Tracer。由于Cypress在Node.js环境中运行,我们需要使用Node.js的SDK。
# 在Cypress项目目录下安装Node.js的OTel依赖
npm install --save-dev @opentelemetry/sdk-node @opentelemetry/exporter-otlp-grpc
// cypress/support/e2e.ts
import './commands';
import { NodeTracerProvider } from '@opentelemetry/sdk-node';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-grpc';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
// 初始化一个用于Cypress测试执行器的Tracer Provider
const provider = new NodeTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'cypress-e2e-runner',
'test.framework': 'Cypress',
}),
});
// 将追踪数据发送到Jaeger
const exporter = new OTLPTraceExporter({
url: 'http://localhost:4317', // 指向Jaeger OTLP gRPC endpoint
});
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
provider.register();
const tracer = trace.getTracer('cypress-tracer');
let rootSpan;
// 在所有测试开始前,创建一个根Span
before(() => {
// 关键:从环境变量读取由CI Job注入的父上下文
// 这使得Cypress的追踪成为CI Job追踪的一部分
const parentTraceparent = Cypress.env('TRACEPARENT');
let parentContext = context.active();
if (parentTraceparent) {
// 解析traceparent并创建父上下文
const [_, traceId, parentId, flags] = parentTraceparent.split('-');
parentContext = trace.setSpanContext(parentContext, {
traceId,
spanId: parentId,
traceFlags: parseInt(flags, 16),
isRemote: true
});
}
rootSpan = tracer.startSpan('e2e-test-suite', undefined, parentContext);
cy.wrap(rootSpan).as('rootSpan');
});
// 在每个测试用例运行后,检查状态并结束Span
afterEach(function () {
cy.get('@currentTestSpan').then(currentTestSpan => {
if (this.currentTest.state === 'failed') {
currentTestSpan.setStatus({ code: SpanStatusCode.ERROR, message: this.currentTest.err.message });
currentTestSpan.recordException(this.currentTest.err);
} else {
currentTestSpan.setStatus({ code: SpanStatusCode.OK });
}
currentTestSpan.end();
});
});
// 所有测试结束后,结束根Span并强制刷新数据
after(() => {
cy.get('@rootSpan').then(rootSpan => {
rootSpan.end();
// 强制将所有缓冲的span发送出去,确保CI任务结束前数据已上报
provider.forceFlush().then(() => {
console.log("Cypress spans flushed.");
}).catch(err => {
console.error("Error flushing Cypress spans:", err);
});
});
});
cypress/support/commands.ts
在这里我们定义自定义命令,并重写内置命令。
// cypress/support/commands.ts
import { trace, context, propagation } from '@opentelemetry/api';
const tracer = trace.getTracer('cypress-tracer');
// 为每个测试用例创建一个Span
Cypress.Commands.add('startTestSpan', (testTitle) => {
cy.get('@rootSpan').then(rootSpan => {
const parentContext = trace.setSpan(context.active(), rootSpan);
const span = tracer.startSpan(testTitle, undefined, parentContext);
cy.wrap(span).as('currentTestSpan');
});
});
// 重写 cy.visit 来注入 traceparent
Cypress.Commands.overwrite('visit', (originalFn, url, options) => {
return cy.get('@currentTestSpan').then(span => {
const activeContext = trace.setSpan(context.active(), span);
const headers = options?.headers || {};
// 将当前活动的Span上下文注入到headers中
propagation.inject(activeContext, headers);
const newOptions = { ...options, headers };
return originalFn(url, newOptions);
});
});
// 这是一个新的自定义命令,用于发起带追踪的API请求
Cypress.Commands.add('traceRequest', (method, url, body) => {
cy.get('@currentTestSpan').then(span => {
const activeContext = trace.setSpan(context.active(), span);
const headers = {};
propagation.inject(activeContext, headers);
return cy.request({ method, url, body, headers });
});
});
// 在每个it块开始时调用我们的自定义命令
beforeEach(function() {
cy.startTestSpan(this.currentTest.title);
});
测试用例 (cypress/e2e/spec.cy.ts)
// cypress/e2e/spec.cy.ts
describe('AI Prediction App', () => {
it('should get a prediction successfully', () => {
// cy.visit 已经被重写,会自动注入追踪头
cy.visit('http://localhost:5173');
cy.get('input[type="text"]').type('A sample text for prediction');
cy.get('button').click();
cy.contains('Processed text').should('be.visible');
});
it('should handle backend errors gracefully', () => {
cy.visit('http://localhost:5173');
// 我们使用 cy.traceRequest 来直接测试API,它也会注入追踪头
cy.traceRequest('POST', 'http://localhost:8000/api/predict', {
text: 'trigger an error'
}).then(response => {
expect(response.status).to.eq(500);
expect(response.body).to.have.property('error', 'Preprocessing failed due to invalid input');
});
});
});
现在运行Cypress测试,你将在Jaeger中看到名为cypress-e2e-runner的服务,它的Span会成为整个测试链路的父节点,完美地将前端和后端追踪串联起来。
第四步:在CI/CD流水线中闭合追踪环路
最后一步是将CI Job本身也纳入追踪。我们将使用GitLab CI作为示例。思路是在CI脚本的开始和结束阶段,手动创建和结束Span。otel-cli是一个非常好用的工具,可以让我们在Shell脚本中与OpenTelemetry交互。
.gitlab-ci.yml
stages:
- test
e2e_tests:
stage: test
image: cypress/base:16.14.2-slim # 一个包含Cypress和Node环境的镜像
before_script:
# 安装otel-cli
- apt-get update && apt-get install -y wget
- wget https://github.com/equinix-labs/otel-cli/releases/download/v0.3.0/otel-cli_0.3.0_linux_amd64.tar.gz
- tar -xzf otel-cli_0.3.0_linux_amd64.tar.gz
- mv otel-cli /usr/local/bin/
# 配置otel-cli连接到Jaeger
- export OTEL_EXPORTER_OTLP_ENDPOINT="http://jaeger:4317"
- export OTEL_SERVICE_NAME="gitlab-ci-pipeline"
script:
# 1. 开始一个CI Job的根Span
- TRACEPARENT=$(otel-cli span --name "e2e-test-job" --start-time $(date +%s.%N) --kind client)
- echo "Started root span with TRACEPARENT=${TRACEPARENT}"
# 2. 将traceparent作为环境变量传递给Cypress
# cypress/support/e2e.ts 中的代码会读取这个环境变量
- export CYPRESS_TRACEPARENT="${TRACEPARENT}"
# 安装项目依赖并运行测试
- npm ci
# 这里的URL需要指向在CI环境中可访问的服务
- npm run cypress:run -- --config baseUrl=http://host.docker.internal:5173 --env TRACEPARENT="${TRACEPARENT}"
# 3. 结束CI Job的Span。根据CI_JOB_STATUS判断成功或失败
- if [ "$CI_JOB_STATUS" == "success" ]; then
- otel-cli span --traceparent "${TRACEPARENT}" --end-time $(date +%s.%N) --status-code OK
- else
- otel-cli span --traceparent "${TRACEPARENT}" --end-time $(date +%s.%N) --status-code ERROR --status-description "Job failed with status ${CI_JOB_STATUS}"
- fi
这里的关键在于 otel-cli span 命令。它会生成一个 traceparent 字符串,格式为 version-traceId-spanId-flags。我们将其捕获并作为环境变量 CYPRESS_TRACEPARENT 注入到Cypress执行环境中。这样,Cypress创建的e2e-test-suite Span就会自动成为e2e-test-job Span的子Span。
方案的局限性与未来展望
我们已经成功构建了一个从CI Job到后端AI服务全覆盖的分布式追踪系统。当一个E2E测试失败时,我们不再是无头苍蝇。通过CI日志中的traceId,我们可以直接在Jaeger中筛选出从CI任务触发、到Cypress执行、再到浏览器操作和后端API调用的完整链路,极大地缩短了故障排查时间。
当前方案的局限性:
- 性能开销: 全链路追踪并非没有成本。特别是在高并发的测试执行或生产环境中,无差别的全量采样会给网络和存储带来巨大压力。在生产环境中,必须引入基于头部的或基于尾部的采样策略。
- 上下文传播的脆弱性: 整个方案依赖于
traceparent头的正确传递。任何一个环节(如网络代理、API网关)如果丢失或篡改了这个头,追踪链就会断裂。这要求整个技术栈都支持并正确配置了W3C Trace Context传播。 - 工具链复杂度: 引入
otel-cli和在多处进行埋点增加了CI/CD和应用代码的复杂度。这对团队的技能和维护成本提出了更高的要求。
未来的优化路径:
- 关联Metrics和Logs: 真正的可观测性是Traces, Metrics, Logs三位一体的。下一步应该是在所有日志中自动注入
traceId和spanId,并在监控指标(如Prometheus)的标签中也加入这些上下文信息,实现三者之间的无缝跳转和关联分析。 - GitOps集成深化: 目前我们只追踪了CI部分。一个完整的GitOps流程还包括ArgoCD或Flux的同步过程。可以通过开发自定义的Controller或利用其钩子(Hooks),将部署过程也作为Span纳入到追踪链路中,实现从代码提交到服务上线的端到端可观测。
- 自动化根因分析: 拥有了完整的追踪数据后,可以探索利用AI/ML技术对追踪数据进行分析,自动识别异常模式、性能瓶颈,甚至预测潜在的故障点,从被动响应转向主动预防。