构建异构技术栈的全链路可观测性:从 iOS 客户端到 Koa 与 Phoenix 后端的数据追踪及 Snowflake 归集


当团队的技术栈自由度过高,就会不可避免地走向监控孤岛。我们的移动端团队选择了原生的 iOS 开发,API 网关层为了快速迭代和轻量化使用了 Koa.js,而核心的高并发业务则由 Elixir/Phoenix 驱动。这种组合在各自领域都表现出色,但当一个用户在 iOS 应用上的操作引发了跨越三层系统的性能问题时,排查工作就变成了一场灾难。日志分散、指标不统一、没有端到端的请求追踪,定位一个简单的延迟问题需要协调三个团队,耗时数天。

问题的核心是缺乏一个统一的上下文,将用户在设备上的点击与后端深处数据库的慢查询关联起来。市面上的商业 APM 方案成本高昂,且对 Elixir 生态的支持普遍滞后。更重要的是,我们不仅需要实时的故障排查,还希望将这些丰富的可观测性数据与 Snowflake 中的业务数据结合,进行长期的性能趋势分析、用户行为与系统性能的关联洞察。这要求我们选择一个开放、可扩展的解决方案。

最终方案围绕 OpenTelemetry 展开。它提供了一套统一的规范和工具,用于在异构系统中生成、收集和导出遥测数据(追踪、指标、日志)。我们的目标是实现一条完整的链路:

  1. iOS Client: 用户操作生成一个 Trace 的起点。
  2. Koa BFF: 接收来自客户端的请求,作为链路的中间站,并向下游传递上下文。
  3. Phoenix Core Service: 处理核心业务逻辑,成为链路的另一个关键部分。
  4. OpenTelemetry Collector: 接收所有遥测数据,进行处理和聚合。
  5. 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,功能完善。
  • 劣势:
    1. 高昂成本: 基于 Agent 数量或数据流量的定价模型,在我们的规模下是一笔巨大的开销。
    2. 厂商锁定: 一旦深度集成,迁移成本极高。
    3. 支持度不均: 对 Node.js 和 iOS 的支持很好,但对 Elixir/Phoenix 的支持通常是社区贡献或功能有限的。
    4. 数据孤岛: APM 数据存储在厂商的平台上,与我们 Snowflake 中的核心业务数据割裂,进行深度关联分析非常困难或需要支付额外的数据导出费用。

最终选择:基于 OpenTelemetry 的自建管道

我们决定采用 OpenTelemetry (OTel) 标准。它是一个 CNCF 的孵化项目,旨在标准化遥测数据的生成和采集。

  • 优势:
    1. 开放标准: 完全开源,无厂商锁定。未来可以随时切换后端,例如从 Snowflake 切换到 Prometheus 或任何其他兼容的系统。
    2. 生态广泛: 主流语言和框架都有官方或社区支持的良好实现。
    3. 控制力: 我们可以完全控制数据采集的粒度、采样率以及数据处理和存储的整个流程。
    4. 数据融合: 将遥测数据作为一等公民导入数据仓库,这为我们打开了将运营数据与业务数据结合分析的大门。

接下来的核心工作是在每个技术栈中落地 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/2Tracer.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。

  1. Collector to Kafka: OTel Collector 将 OTLP 数据以 JSON 格式发送到 Kafka 的 otlp_traces topic。
  2. 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 的方案赋予了我们前所未有的洞察力。它打破了技术栈的壁垒,让我们能够从一个用户的指尖触摸开始,一直追踪到系统最深处的数据库交互,并将这一切与业务成果关联起来。这才是可观测性真正的价值所在。


  目录