当团队的技术栈自由度过高,就会不可避免地走向监控孤岛。我们的移动端团队选择了原生的 iOS 开发,API 网关层为了快速迭代和轻量化使用了 Koa.js,而核心的高并发业务则由 Elixir/Phoenix 驱动。这种组合在各自领域都表现出色,但当一个用户在 iOS 应用上的操作引发了跨越三层系统的性能问题时,排查工作就变成了一场灾难。日志分散、指标不统一、没有端到端的请求追踪,定位一个简单的延迟问题需要协调三个团队,耗时数天。
问题的核心是缺乏一个统一的上下文,将用户在设备上的点击与后端深处数据库的慢查询关联起来。市面上的商业 APM 方案成本高昂,且对 Elixir 生态的支持普遍滞后。更重要的是,我们不仅需要实时的故障排查,还希望将这些丰富的可观测性数据与 Snowflake 中的业务数据结合,进行长期的性能趋势分析、用户行为与系统性能的关联洞察。这要求我们选择一个开放、可扩展的解决方案。
最终方案围绕 OpenTelemetry 展开。它提供了一套统一的规范和工具,用于在异构系统中生成、收集和导出遥测数据(追踪、指标、日志)。我们的目标是实现一条完整的链路:
- iOS Client: 用户操作生成一个 Trace 的起点。
- Koa BFF: 接收来自客户端的请求,作为链路的中间站,并向下游传递上下文。
- Phoenix Core Service: 处理核心业务逻辑,成为链路的另一个关键部分。
- OpenTelemetry Collector: 接收所有遥测数据,进行处理和聚合。
- Snowflake: 作为所有遥测数据的最终存储和分析平台。
这个架构的整体数据流如下:
graph TD
subgraph "用户设备"
A[iOS App]
end
subgraph "后端服务"
B[Koa BFF]
C[Phoenix Core Service]
end
subgraph "数据管道"
D[OpenTelemetry Collector]
end
subgraph "数据仓库"
E[Snowflake]
end
A -- HTTP Request with Trace Context --> B
B -- gRPC/HTTP Request with Trace Context --> C
A -- OTLP Export --> D
B -- OTLP Export --> D
C -- OTLP Export --> D
D -- OTLP over HTTP --> E[Snowpipe or Kafka Connector]
方案 A:各扫门前雪(被否决)
初步讨论时,有工程师提出维持现状,各自优化。iOS 团队使用 Xcode Instruments 和 MetricKit;Koa 团队使用 PM2 结合 V8 Inspector;Phoenix 团队利用 BEAM 强大的内省能力和 LiveDashboard。
- 优势: 实施成本低,各团队技术栈无需改变。
- 劣势: 这是问题的根源,而非解决方案。无法建立跨服务联系,问题排查效率极低。例如,无法确定是客户端网络慢、Koa 节点 GC 停顿,还是 Phoenix 服务的数据库连接池耗尽导致了用户感知的延迟。这个方案在第一次跨团队故障复盘会上就被彻底否决。
方案 B:商业 APM 套件(被否决)
第二个方案是引入成熟的商业 APM 解决方案。
- 优势: 开箱即用,提供统一的 UI,功能完善。
- 劣势:
- 高昂成本: 基于 Agent 数量或数据流量的定价模型,在我们的规模下是一笔巨大的开销。
- 厂商锁定: 一旦深度集成,迁移成本极高。
- 支持度不均: 对 Node.js 和 iOS 的支持很好,但对 Elixir/Phoenix 的支持通常是社区贡献或功能有限的。
- 数据孤岛: APM 数据存储在厂商的平台上,与我们 Snowflake 中的核心业务数据割裂,进行深度关联分析非常困难或需要支付额外的数据导出费用。
最终选择:基于 OpenTelemetry 的自建管道
我们决定采用 OpenTelemetry (OTel) 标准。它是一个 CNCF 的孵化项目,旨在标准化遥测数据的生成和采集。
- 优势:
- 开放标准: 完全开源,无厂商锁定。未来可以随时切换后端,例如从 Snowflake 切换到 Prometheus 或任何其他兼容的系统。
- 生态广泛: 主流语言和框架都有官方或社区支持的良好实现。
- 控制力: 我们可以完全控制数据采集的粒度、采样率以及数据处理和存储的整个流程。
- 数据融合: 将遥测数据作为一等公民导入数据仓库,这为我们打开了将运营数据与业务数据结合分析的大门。
接下来的核心工作是在每个技术栈中落地 OpenTelemetry SDK,并确保 Trace Context(遵循 W3C Trace Context 规范)在整个调用链中正确传播。
核心实现:Phoenix 服务层(Elixir)
我们从最核心的 Phoenix 服务开始。Elixir 社区对 OpenTelemetry 的支持非常出色。
首先,在 mix.exs 中添加依赖:
# mix.exs
def deps do
[
# ... other deps
{:opentelemetry_api, "~> 1.2"},
{:opentelemetry, "~> 1.2"},
{:opentelemetry_exporter, "~> 1.4"}, # OTLP Exporter
{:opentelemetry_phoenix, "~> 1.2"},
{:opentelemetry_ecto, "~> 1.2"}
]
end
接下来,在 config/config.exs 中配置 OTLP exporter,将数据发送到 OpenTelemetry Collector。
# config/config.exs
import Config
config :opentelemetry, :processors,
otel_batch_processor: %{
exporter: {:opentelemetry_exporter, %{
endpoints: [System.get_env("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")]
}}
}
# 在应用启动时配置和初始化
# application.ex
def start(_type, _args) do
# ...
setup_opentelemetry()
# ...
Supervisor.start_link(children, opts)
end
defp setup_opentelemetry do
require OpenTelemetry.Tracer
# Phoenix 和 Ecto 的自动 instrumentation
OpentelemetryPhoenix.setup()
OpentelemetryEcto.setup([:my_app, :repo])
# 设置全局元数据,比如服务名和版本
:opentelemetry.set_application_attribute_map(%{
:"service.name" => "phoenix-core-service",
:"service.version" => "1.0.2",
:"deployment.environment" => System.get_env("MIX_ENV", "dev")
})
end
opentelemetry_phoenix 会自动为每个进入 Phoenix Router 的请求创建一个 Span,opentelemetry_ecto 则会为每个数据库查询创建子 Span。我们还可以在业务逻辑中创建自定义 Span 来追踪更细粒度的操作。
# lib/my_app_web/controllers/user_controller.ex
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
require OpenTelemetry.Tracer
alias OpenTelemetry.Tracer
def show(conn, %{"id" => id}) do
# 自动创建的 Phoenix Controller Span 会成为当前 Span
# 创建一个自定义的子 Span 来追踪特定的业务逻辑
Tracer.with_span "fetch_user_profile_and_permissions" do
# 为 Span 添加属性,便于后续查询分析
Tracer.set_attribute("user.id", id)
user = Accounts.get_user!(id)
# 模拟一个耗时的权限检查操作
permissions = check_permissions_for(user)
Tracer.add_event("Permissions checked", %{"permission_count" => Enum.count(permissions)})
render(conn, "show.json", user: user, permissions: permissions)
end
end
defp check_permissions_for(user) do
# 在这个函数内部创建一个更细粒度的 Span
Tracer.with_span "permission_check_rpc" do
Tracer.set_attribute("system", "auth_service")
:timer.sleep(50) # 模拟 RPC 调用延迟
# 真实的权限检查逻辑...
["read:profile", "write:comments"]
end
end
end
这里的 Tracer.with_span/2 是关键,它能确保 Span 的生命周期被正确管理。同时,Tracer.set_attribute/2 和 Tracer.add_event/2 提供了丰富的上下文信息,这些信息最终都会进入 Snowflake。
核心实现:Koa 网关层(Node.js)
Koa 作为 BFF,其核心职责是接收 iOS 客户端的请求并将其转发给 Phoenix 服务。确保 Trace Context 能从入站请求正确传递到出站请求是这一层的关键。
我们使用 @opentelemetry/sdk-node 来自动化大部分工作。
// tracer.js
// 这个文件必须在你的应用代码被 require/import 之前首先执行
//
// 启动命令: node -r ./tracer.js app.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
// OTLP Exporter 配置
const traceExporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
});
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'koa-bff-service',
[SemanticResourceAttributes.SERVICE_VERSION]: '0.5.1',
}),
traceExporter,
// 自动 instrument 主流的 Node.js 库,例如 koa, http, grpc, pg 等
instrumentations: [getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': {
// 在生产环境中,fs 的 tracing 会产生大量噪音,通常建议禁用
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 SDK started for koa-bff-service');
有了这个 tracer.js,我们几乎不需要修改 Koa 的应用代码。@opentelemetry/instrumentation-koa 和 @opentelemetry/instrumentation-http 会自动工作。
// app.js
const Koa = require('koa');
const Router = require('koa-router');
const axios = require('axios'); // 确保 axios 也被自动 instrumented
const app = new Koa();
const router = new Router();
const PHOENIX_SERVICE_URL = process.env.PHOENIX_SERVICE_URL || 'http://localhost:4000';
router.get('/users/:id', async (ctx) => {
// Koa instrumentation 已经从入站请求头中提取了 trace context
// 并创建了一个新的 server span。
const { id } = ctx.params;
try {
// 当我们使用 axios 发起请求时, http instrumentation 会自动:
// 1. 创建一个新的 client span,作为当前 server span 的子 span。
// 2. 将当前的 trace context (traceparent header) 注入到出站请求头中。
const response = await axios.get(`${PHOENIX_SERVICE_URL}/api/users/${id}`);
ctx.body = response.data;
ctx.status = 200;
} catch (error) {
// 错误也会被自动记录到 span 中
console.error('Failed to fetch user from Phoenix service', error);
ctx.status = error.response ? error.response.status : 500;
ctx.body = { error: 'Internal Server Error' };
}
});
app.use(router.routes()).use(router.allowedMethods());
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Koa BFF listening on port ${port}`);
});
这里的魔法在于自动 instrumentation。当 axios.get被调用时,traceparent HTTP 头被自动附加,Phoenix 服务接收到这个头后,就能将它的 Span 与 Koa 的 Span 关联起来,形成一条完整的链路。
核心实现:iOS 客户端层(Swift)
在客户端上实现追踪是最具挑战性的部分,因为环境更加复杂(网络切换、应用生命周期等)。我们使用 opentelemetry-swift SDK。
首先,通过 Swift Package Manager 添加依赖:https://github.com/open-telemetry/opentelemetry-swift.git
在 AppDelegate 或应用启动的中心位置配置 SDK:
// OtelManager.swift
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryExporterOtlpHttp
class OtelManager {
static let shared = OtelManager()
private init() {}
func setup() {
// 定义资源属性
let resource = Resource(attributes: [
"service.name": AttributeValue.string("ios-client-app"),
"service.version": AttributeValue.string(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"),
"os.name": AttributeValue.string("iOS"),
"os.version": AttributeValue.string(UIDevice.current.systemVersion)
])
// 创建 OTLP exporter
let exporter = OtlpHttpTraceExporter(
endpoint: URL(string: "http://your-otel-collector-endpoint:4318/v1/traces")!
)
// 使用批量处理器提高效率,避免每个 span 都立即发送
let processor = BatchSpanProcessor(spanExporter: exporter)
// 设置全局的 TracerProvider
OpenTelemetry.registerTracerProvider(
tracerProvider: TracerProviderBuilder()
.add(spanProcessor: processor)
.with(resource: resource)
.build()
)
}
func getTracer() -> Tracer {
return OpenTelemetry.instance.tracerProvider.get(instrumentationName: "MyAppInstrumentation", instrumentationVersion: "1.0.0")
}
}
// AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
OtelManager.shared.setup()
return true
}
配置好之后,我们需要 instrument 网络请求。最直接的方式是封装 URLSession。
// NetworkService.swift
import Foundation
import OpenTelemetryApi
class NetworkService {
private let tracer = OtelManager.shared.getTracer()
func fetchUserProfile(userId: String, completion: @escaping (Result<Data, Error>) -> Void) {
// 1. 创建一个代表整个操作的父 Span
let span = tracer.spanBuilder(spanName: "fetchUserProfile_ButtonTap").startSpan()
// 2. 将这个 Span 设为当前活动 Span,后续创建的 Span 都会成为它的子 Span
OpenTelemetry.instance.contextProvider.withActiveSpan(span) {
guard var urlComponents = URLComponents(string: "http://your-koa-bff-endpoint:3000/users/\(userId)") else {
span.setStatus(status: .error(description: "Invalid URL"))
span.end()
return
}
var request = URLRequest(url: urlComponents.url!)
request.httpMethod = "GET"
// 3. 关键步骤:将当前的 trace context 注入到 HTTP 请求头中
// 这会自动添加 `traceparent` header
OpenTelemetry.instance.contextProvider.inject(into: &request, using: HttpTraceContextPropagator())
// 创建一个代表网络请求本身的子 Span
let networkSpan = tracer.spanBuilder(spanName: "HTTP GET /users/:id").setSpanKind(spanKind: .client).startSpan()
let task = URLSession.shared.dataTask(with: request) { data, response, error in
// 确保在回调中结束 span
defer {
networkSpan.end()
span.end() // 结束父 Span
}
if let error = error {
networkSpan.setStatus(status: .error(description: error.localizedDescription))
completion(.failure(error))
return
}
if let httpResponse = response as? HTTPURLResponse {
networkSpan.setAttribute(key: "http.status_code", value: httpResponse.statusCode)
if (200...299).contains(httpResponse.statusCode) {
networkSpan.setStatus(status: .ok)
} else {
network.setStatus(status: .error(description: "HTTP Status Code: \(httpResponse.statusCode)"))
}
}
if let data = data {
completion(.success(data))
}
}
task.resume()
}
}
}
现在,当用户在 iOS App 中点击按钮调用 fetchUserProfile 时,一个完整的、跨越客户端、网关和核心服务的分布式追踪链路就形成了。
数据归集:OpenTelemetry Collector 与 Snowflake
所有服务都将 OTLP 数据发送到一个中心化的 OpenTelemetry Collector。Collector 是一个高性能的遥测数据管道,它可以接收、处理和转发数据。
一个最小化的 collector 配置文件 otel-collector-config.yaml 如下:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
# 批量处理以提高吞吐量
timeout: 1s
send_batch_size: 512
memory_limiter:
# 防止 collector 耗尽内存
check_interval: 1s
limit_mib: 2000
spike_limit_mib: 400
exporters:
# 方案一:直接通过 Kafka/Kinesis 导出,再由 Snowpipe 消费
kafka:
brokers: ["kafka-broker-1:9092"]
topic: "otlp_traces"
encoding: "otlp_json"
# 方案二:导出到 S3,再由 Snowpipe 自动摄取
# aws_s3:
# region: "us-east-1"
# s3_uploader:
# bucket: "my-otel-data-bucket"
# output_file_prefix: "traces"
# 用于调试,在控制台打印遥测数据
logging:
loglevel: debug
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [kafka, logging] # 同时导出到 Kafka 和控制台
在真实项目中,直接从 Collector 写入 Snowflake 的 exporter 并不常见。更稳健的架构是 Collector -> Kafka/S3 -> Snowflake。我们选择了 Kafka + Snowflake Connector/Snowpipe。
- Collector to Kafka: OTel Collector 将 OTLP 数据以 JSON 格式发送到 Kafka 的
otlp_tracestopic。 - Kafka to Snowflake: 使用 Snowflake 的 Kafka Connector 或设置 Snowpipe 自动从 Kafka topic 中拉取新消息。数据以
VARIANT类型加载到一个原始数据表中。
在 Snowflake 中创建目标表:
CREATE OR REPLACE TABLE raw_traces (
raw_data VARIANT,
ingestion_time TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);
然后,我们可以创建视图或使用物化视图来解析 VARIANT JSON 数据,将其转换为结构化的列式格式,以便于分析。
CREATE OR REPLACE VIEW parsed_traces AS
SELECT
-- 从复杂的 JSON 结构中提取核心字段
raw_data:resourceSpans[0]:resource.attributes[?(@.key=='service.name')].value.stringValue::STRING AS service_name,
span.traceId::STRING AS trace_id,
span.spanId::STRING AS span_id,
span.parentSpanId::STRING AS parent_span_id,
span.name::STRING AS span_name,
(span.startTimeUnixNano / 1000000)::TIMESTAMP_NTZ AS start_time,
(span.endTimeUnixNano / 1000000)::TIMESTAMP_NTZ AS end_time,
(span.endTimeUnixNano - span.startTimeUnixNano) / 1000000 AS duration_ms,
-- 提取所有属性
(
SELECT OBJECT_AGG(attr.key, attr.value.stringValue)
FROM LATERAL FLATTEN(input => span.attributes) AS attr
) AS attributes,
span.status.code::STRING AS status_code
FROM
raw_traces,
LATERAL FLATTEN(input => raw_data:resourceSpans[0]:scopeSpans[0]:spans) AS span;
有了这个 parsed_traces 视图,我们就可以用熟悉的 SQL 来回答复杂的问题了:
-- 查询过去24小时内,某个特定用户(通过自定义属性 user.id 识别)
-- 经历的最慢的5个完整链路(从 iOS 客户端开始)
WITH client_spans AS (
SELECT trace_id, start_time
FROM parsed_traces
WHERE service_name = 'ios-client-app' AND parent_span_id = '' -- 根 Span
AND attributes:"user.id" = 'user-123'
AND start_time >= DATEADD(day, -1, CURRENT_TIMESTAMP())
),
trace_durations AS (
SELECT
t.trace_id,
MIN(t.start_time) AS trace_start_time,
MAX(t.end_time) AS trace_end_time,
DATEDIFF(ms, MIN(t.start_time), MAX(t.end_time)) AS total_duration_ms
FROM parsed_traces t
JOIN client_spans cs ON t.trace_id = cs.trace_id
GROUP BY t.trace_id
)
SELECT trace_id, trace_start_time, total_duration_ms
FROM trace_durations
ORDER BY total_duration_ms DESC
LIMIT 5;
当前方案的局限性与未来展望
这个方案并非没有缺点。首先,OpenTelemetry 的 Swift SDK 相较于其在 Java 或 Go 生态中的实现,成熟度和功能丰富度还有一定差距,需要我们投入更多精力去封装和测试。其次,自建和维护 OpenTelemetry Collector 以及从 Kafka 到 Snowflake 的数据管道,引入了额外的运维成本,这需要团队具备相应的 SRE 能力。
再者,Snowflake 主要用于分析和事后排查,其数据加载的延迟(即使使用 Snowpipe,也通常是分钟级)不适合用于实时的、秒级的告警。因此,我们的架构在未来可能会演变为双出口模式:一份数据流向 Snowflake 用于深度分析,另一份(可能是指标或采样后的追踪)流向 Prometheus/VictoriaMetrics 或 ClickHouse 这类系统,以满足实时监控和告警的需求。
尽管存在这些挑战,但这套基于 OpenTelemetry 的方案赋予了我们前所未有的洞察力。它打破了技术栈的壁垒,让我们能够从一个用户的指尖触摸开始,一直追踪到系统最深处的数据库交互,并将这一切与业务成果关联起来。这才是可观测性真正的价值所在。