构建面向移动端SLO的全栈可观测性面板


我们的 Swift 应用性能一度是个黑箱。除了崩溃报告,我们对用户体验的量化认知几乎为零:API 平均延迟是多少?首页启动耗时超过3秒的用户占比多大?这些数据的缺失,让关于服务等级目标(SLO)的讨论沦为空谈。技术团队无法向产品部门承诺或证明应用的可靠性,更无法在功能迭代与稳定性投入之间做出数据驱动的决策。问题的核心在于,我们需要一个从客户端数据采集、CI/CD流程保障、后端聚合存储到前端可视化展示的完整闭环。

这个复盘日志记录了我们如何利用一个看似不寻常的技术栈——Swift, Prometheus, Ant Design, 和 Tailwind CSS,并以移动端CI/CD流程为粘合剂,从零开始构建一个内部使用的、面向移动端SLO的全栈可观测性面板的过程。

技术选型决策的权衡

最初的构想很简单:在App里打点,发到某个后端服务,然后做个图表。但魔鬼藏在细节里。

  1. 客户端指标采集 (Swift): 我们需要的是数值型、可聚合的指标(Metrics),而非离散的日志(Logs)。比如,api_request_duration_seconds 这个 Histogram 指标,远比 “API /user/profile finished in 253ms” 这样的日志更有价值。因此,我们需要一个能在客户端实现的、兼容主流监控生态的指标库。直接在 Swift 应用内实现一个轻量级的 Prometheus 指标生成器是最高效的选择,它能让我们完全控制指标的格式和元数据。

  2. 指标接收与存储 (Prometheus): Prometheus 是云原生监控的事实标准,其强大的查询语言 PromQL 是我们进行 SLO 计算的基石。但它有一个核心矛盾:Prometheus 是一个 Pull 模型,它主动去拉取目标的指标;而移动客户端位于 NAT 之后,无法被外部服务器直接访问。解决方案是引入 Prometheus Pushgateway。客户端将指标主动推送到 Pushgateway,Prometheus 再从 Pushgateway 这里拉取。这是一个在真实项目中常见的模式,但它也引入了新的架构考量。

  3. CI/CD流程集成 (Fastlane & GitHub Actions): 监控代码如果不能随应用版本迭代而可靠地部署,那它就是不可信的。我们需要在 CI/CD 流程中确保:

    • Pushgateway 的地址和认证信息能被安全地注入到构建产物中,而不是硬编码在代码里。
    • 每次构建都自动运行单元测试,验证指标采集模块的逻辑正确性。
    • 版本号、构建环境(Debug/Release)等关键信息能作为标签(Labels)附加到所有指标上,以便在 Prometheus 中进行多维度切分。
  4. 可视化面板 (Ant Design & Tailwind CSS): 我们需要一个内部平台来展示SLO状态、错误预算(Error Budget)消耗情况以及关键指标趋势。为什么不用 Grafana?因为我们的需求远不止是图表展示。我们希望将 SLO 数据与版本发布信息、功能开关状态、线上告警事件等整合在同一个视图中,这需要高度的定制化。

    • Ant Design: 提供了构建复杂企业级应用所需的全套高质量组件,如表格、统计卡片、日期选择器等。它可以让我们快速搭建出应用的骨架。
    • Tailwind CSS: 解决了 Ant Design 定制化困难的问题。在真实项目中,设计师对像素级对齐、特定的颜色和间距有严格要求,而通过覆盖 antd 的 less 变量是一种非常笨拙且难以维护的方式。Tailwind 的原子化 CSS 类让我们能以极高的效率和灵活性,在 Ant Design 组件的基础上构建出数据密度极高、风格独特的 UI,而无需编写一行传统 CSS。

步骤化实现:从客户端到仪表盘

1. Swift 客户端指标采集模块

我们没有引入庞大的第三方库,而是自己实现了一个轻量级的 MetricsManager。它的核心职责是:构建符合 Prometheus 文本格式的指标、批量处理、线程安全以及通过 HTTP POST 推送到 Pushgateway。

// MetricsManager.swift

import Foundation

// 定义指标类型,简化处理
public enum MetricType: String {
    case counter = "COUNTER"
    case gauge = "GAUGE"
    case histogram = "HISTOGRAM"
}

// 指标结构体
public struct Metric {
    let name: String
    let value: Double
    let labels: [String: String]
    let type: MetricType
}

// 核心管理器,单例模式
public final class MetricsManager {

    public static let shared = MetricsManager()

    private var pushgatewayURL: URL?
    private var jobName: String = "ios-app"
    private var instanceName: String = UIDevice.current.identifierForVendor?.uuidString ?? "unknown-device"

    // 使用 actor 确保对 metricsBuffer 的并发访问安全
    private actor MetricsBuffer {
        var metrics: [Metric] = []

        func add(_ metric: Metric) {
            metrics.append(metric)
        }

        func drain() -> [Metric] {
            let drained = metrics
            metrics.removeAll()
            return drained
        }
    }

    private let buffer = MetricsBuffer()
    private let pushQueue = DispatchQueue(label: "com.yourapp.metrics.pushQueue", qos: .background)

    private init() {}

    // 必须在 App 启动时配置
    public func configure(pushgatewayURL: String, jobName: String, appVersion: String, environment: String) {
        guard let url = URL(string: pushgatewayURL) else {
            print("[MetricsManager] Error: Invalid Pushgateway URL")
            return
        }
        self.pushgatewayURL = url
        self.jobName = jobName
        // 将关键信息作为实例标签的一部分
        self.instanceName = "version=\(appVersion),env=\(environment),id=\(instanceName)"
    }

    // 记录指标的公共接口
    public func record(_ metric: Metric) {
        Task {
            await buffer.add(metric)
        }
    }

    // 定期或在特定事件(如App进入后台)时调用
    public func pushMetrics() {
        pushQueue.async { [weak self] in
            guard let self = self else { return }
            
            Task {
                let metricsToPush = await self.buffer.drain()
                if metricsToPush.isEmpty {
                    return
                }
                
                self.sendToPushgateway(metrics: metricsToPush)
            }
        }
    }

    private func sendToPushgateway(metrics: [Metric]) {
        guard let pushgatewayURL = self.pushgatewayURL else {
            print("[MetricsManager] Error: Pushgateway URL not configured.")
            return
        }

        // 构建符合 Prometheus Text Exposition Format 的 payload
        let payload = metrics.map { metric -> String in
            let labelString = metric.labels.map { key, value in "\(key)=\"\(value)\"" }.joined(separator: ",")
            let fullLabels = labelString.isEmpty ? "" : "{\(labelString)}"
            
            return """
            # TYPE \(metric.name) \(metric.type.rawValue.lowercased())
            \(metric.name)\(fullLabels) \(metric.value)
            """
        }.joined(separator: "\n") + "\n"

        // 构建请求
        // URL 格式: /metrics/job/{jobName}/instance/{instanceName}
        // 注意:instanceName 必须经过 URL-safe base64 编码,因为其中可能包含逗号等特殊字符
        guard let encodedInstance = self.instanceName.data(using: .utf8)?.base64EncodedString() else { return }
        let finalURL = pushgatewayURL
            .appendingPathComponent("metrics")
            .appendingPathComponent("job")
            .appendingPathComponent(self.jobName)
            .appendingPathComponent("instance@base64")
            .appendingPathComponent(encodedInstance)

        var request = URLRequest(url: finalURL)
        request.httpMethod = "POST"
        request.httpBody = payload.data(using: .utf8)
        request.setValue("text/plain; version=0.0.4", forHTTPHeaderField: "Content-Type")

        // 实际的生产级代码中,这里应该使用更健壮的网络库,并包含重试逻辑、错误处理和日志记录
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                print("[MetricsManager] Push failed: \(error.localizedDescription)")
                // 失败处理:可以将 metricsToPush 加回 buffer,但要注意重试策略,避免无限重试
                return
            }
            
            guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
                print("[MetricsManager] Push failed with status code: \((response as? HTTPURLResponse)?.statusCode ?? -1)")
                return
            }
            
            print("[MetricsManager] Successfully pushed \(metrics.count) metrics.")
        }
        task.resume()
    }
}

// 使用示例
// AppDelegate or SwiftUI App struct
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // 这些值应该从 CI/CD 注入,例如通过 Plist 文件
    let gatewayUrl = Bundle.main.object(forInfoDictionaryKey: "PUSHGATEWAY_URL") as! String
    let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
    let buildEnv = Bundle.main.object(forInfoDictionaryKey: "BUILD_ENV") as! String

    MetricsManager.shared.configure(
        pushgatewayURL: gatewayUrl,
        jobName: "swift-client",
        appVersion: appVersion,
        environment: buildEnv
    )
    
    // 记录一个启动耗时指标
    let startupDuration = 1.2 // 实际应为测量值
    let startupMetric = Metric(name: "app_startup_duration_seconds", value: startupDuration, labels: ["type": "cold"], type: .gauge)
    MetricsManager.shared.record(startupMetric)
    
    // 启动一个定时器定期推送
    Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in
        MetricsManager.shared.pushMetrics()
    }
    
    return true
}

这段代码的关键在于 sendToPushgateway 函数中对 Pushgateway URL 格式的正确处理,特别是对 instance 标签的 Base64 编码,这是一个常见的坑。

2. CI/CD 流程:注入配置与保障质量

我们使用 GitHub Actions 和 Fastlane。流水线的核心任务是在构建时动态生成一个 Config.plist 文件,并将其包含在应用包中。

# .github/workflows/build-and-deploy.yml

name: iOS Build & Deploy

on:
  push:
    branches: [ "main" ]

jobs:
  build:
    runs-on: macos-latest
    
    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    # ... 其他步骤,如安装 aple certificates, provisioning profiles ...
    
    - name: Setup Fastlane
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: '2.7'
        bundler-cache: true

    - name: Create Configuration Plist
      env:
        PUSHGATEWAY_URL: ${{ secrets.PROD_PUSHGATEWAY_URL }}
        BUILD_ENV: "production"
      run: |
        echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" > YourApp/Config.plist
        echo "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">" >> YourApp/Config.plist
        echo "<plist version=\"1.0\"><dict>" >> YourApp/Config.plist
        echo "<key>PUSHGATEWAY_URL</key><string>${PUSHGATEWAY_URL}</string>" >> YourApp/Config.plist
        echo "<key>BUILD_ENV</key><string>${BUILD_ENV}</string>" >> YourApp/Config.plist
        echo "</dict></plist>" >> YourApp/Config.plist

    - name: Run Tests & Build with Fastlane
      run: bundle exec fastlane build_and_upload_to_testflight
      
    # ...

Fastlane 的 Fastfile 则负责执行实际的构建命令,并确保 Config.plist 被正确包含。

# fastlane/Fastfile

lane :build_and_upload_to_testflight do
  # 确保配置文件存在
  ensure_config_plist_exists

  # 运行单元测试,确保指标模块工作正常
  run_tests(
    scheme: "YourApp",
    device: "iPhone 14 Pro"
  )

  # 构建应用
  gym(
    scheme: "YourApp",
    workspace: "YourApp.xcworkspace",
    # ... 其他 gym 配置
  )
  
  # 上传到 TestFlight
  pilot(
    # ... pilot 配置
  )
end

private_lane :ensure_config_plist_exists do
  config_path = "../YourApp/Config.plist"
  unless File.exist?(config_path)
    UI.user_error!("Config.plist not found! It should be generated by the CI script.")
  end
end

这个流程将基础设施配置(URL)与应用代码解耦,并加入了质量门禁(单元测试),是保障可观测性数据可靠性的关键一环。

3. Prometheus & PromQL: 定义我们的 SLO

假设我们定义了一个SLO:过去28天,99.5% 的 API 请求延迟必须低于 500ms

在 Swift 客户端,我们上报的是一个 Histogram 类型的指标 api_request_duration_seconds。它包含了多个 bucket(le=”0.1”, le=”0.25”, le=”0.5”, le=”+Inf”)。

Prometheus 服务器上,我们可以用 PromQL 来计算 SLI(服务等级指标):

# 计算在 500ms 内完成的请求数
# sum by (version, env) (rate(api_request_duration_seconds_bucket{job="swift-client", le="0.5"}[28d]))
# 
# 计算总请求数
# sum by (version, env) (rate(api_request_duration_seconds_count{job="swift-client"}[28d]))
#
# SLI: 成功率
(
  sum by (version, env) (rate(api_request_duration_seconds_bucket{job="swift-client", le="0.5"}[28d]))
/
  sum by (version, env) (rate(api_request_duration_seconds_count{job="swift-client"}[28d]))
) * 100

这个查询返回了按版本和环境分组的 SLI 百分比,这就是我们仪表盘的核心数据。

错误预算的计算也很直接: (1 - SLO_Target) * Total_Requests。而错误预算消耗率则更能反映问题的紧急程度。

# 过去1小时的坏事件(延迟 > 500ms)速率
sum(rate(api_request_duration_seconds_count[1h])) - sum(rate(api_request_duration_seconds_bucket{le="0.5"}[1h]))

# 28天的坏事件总预算速率
(1 - 0.995) * sum(rate(api_request_duration_seconds_count[28d]))

# 错误预算消耗速度(burn rate)。如果这个值是 1,代表正以预算允许的速度消耗。
# 如果是 2,代表消耗速度是预算的2倍,错误预算会在14天内耗尽。
# 这是一个非常关键的告警指标。
(
  sum(rate(api_request_duration_seconds_count[1h])) - sum(rate(api_request_duration_seconds_bucket{le="0.5"}[1h]))
)
/
(
  (1 - 0.995) * sum(rate(api_request_duration_seconds_count[28d]))
)

4. 前端仪表盘:Ant Design 与 Tailwind CSS 的化学反应

这是我们将数据转化为洞察的地方。我们使用 Create React App 启动项目,并安装 Ant Design, Tailwind CSS, 和一个图表库。

首先,配置 Tailwind 来与 Ant Design 共存,并覆盖一些 antd 的默认样式。

// tailwind.config.js
module.exports = {
  // 核心:添加一个 antd 不会使用的前缀,避免样式冲突
  // 或者通过 PostCSS 配置在 antd 样式之前引入 Tailwind
  // 这里我们采用更简单的方式,直接使用它
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
  // 重要: 避免重置 antd 样式
  corePlugins: {
    preflight: false,
  }
}

接着,我们创建一个核心组件 SLOIndicator,它负责获取数据并展示。

// src/components/SLOIndicator.tsx
import React, { useState, useEffect } from 'react';
import { Card, Statistic, Progress, Tooltip } from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Line } from '@ant-design/plots'; // Ant Design Charts

// 模拟从 Prometheus HTTP API 获取数据
async function fetchPrometheusData(query: string): Promise<number> {
    const PROMETHEUS_URL = 'http://your-prometheus-server:9090';
    const response = await fetch(`${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`);
    const data = await response.json();
    if (data.status === 'success' && data.data.result.length > 0) {
        return parseFloat(data.data.result[0].value[1]);
    }
    // 生产代码需要更完善的错误处理
    return 0;
}


export const SLOIndicator: React.FC = () => {
    const [sli, setSli] = useState<number>(0);
    const [errorBudget, setErrorBudget] = useState<number>(100);
    const sloTarget = 99.5;

    useEffect(() => {
        const query = `(sum(rate(api_request_duration_seconds_bucket{job="swift-client", le="0.5"}[28d])) / sum(rate(api_request_duration_seconds_count{job="swift-client"}[28d]))) * 100`;
        fetchPrometheusData(query).then(value => setSli(value));
        
        // 计算剩余错误预算
        const budgetRemaining = (sli - sloTarget) / (100 - sloTarget);
        setErrorBudget(Math.max(0, budgetRemaining * 100));

    }, [sli]);

    // 使用 Tailwind CSS 定义样式,这比写 CSS 文件或 Styled-components 更快
    const cardClasses = "bg-gray-800 border-gray-700 text-white shadow-lg";
    const titleClasses = "text-gray-300 font-semibold";
    const valueClasses = sli >= sloTarget ? "text-green-400" : "text-red-500";
    
    return (
        <Card title="API Latency SLO (28d)" className={cardClasses} headStyle={{ borderBottom: '1px solid #4A5568', color: '#E2E8F0' }}>
            <div className="grid grid-cols-2 gap-8">
                <div>
                    <Statistic
                        title={<span className={titleClasses}>Current SLI</span>}
                        value={sli}
                        precision={3}
                        suffix="%"
                        valueStyle={{ color: sli >= sloTarget ? '#48BB78' : '#F56565', fontFamily: 'monospace' }}
                    />
                    <div className="mt-4 text-xs text-gray-400">Target: {sloTarget}%</div>
                </div>
                <div>
                    <span className={titleClasses}>Error Budget Remaining</span>
                    <Tooltip title={`Budget consumed: ${(100 - errorBudget).toFixed(2)}%`}>
                        <InfoCircleOutlined className="ml-2 text-gray-500" />
                    </Tooltip>
                    <Progress
                        percent={errorBudget}
                        strokeColor={{ '0%': '#F56565', '100%': '#48BB78' }}
                        trailColor="rgba(255, 255, 255, 0.1)"
                        format={percent => `${percent?.toFixed(2)}%`}
                        className="mt-2"
                    />
                </div>
            </div>
            {/* 在这里可以添加一个显示错误预算消耗率的 Line chart */}
        </Card>
    );
};

在这个组件里,Ant Design 提供了 Card, Statistic, Progress 这些结构化的组件。而 Tailwind CSS 负责“美学”和“微调”:

  • bg-gray-800, border-gray-700, text-white 快速构建出深色主题的卡片。
  • grid grid-cols-2 gap-8 用 Flexbox/Grid 轻松实现复杂的布局。
  • font-monospace 让数字更具科技感。
  • text-green-400 / text-red-500 根据数据状态动态改变颜色,提供即时视觉反馈。

这种组合兼具了开发效率和定制灵活性,在构建内部工具时是一种非常务实的选择。

graph TD
    subgraph Swift App
        A[User Action] --> B{MetricsManager};
        B -- Batches metrics --> C[POST Request];
    end

    subgraph CI/CD Pipeline
        D[GitHub Actions] --> E{Fastlane Script};
        E -- Injects Config --> F[App Build];
        F --> A;
    end
    
    subgraph Backend
        C --> G[Pushgateway];
        H[Prometheus]-- Pulls --> G;
        I[PromQL Queries] --> H;
    end
    
    subgraph Frontend Dashboard
        J[React App] -- API Call with PromQL --> H;
        K[Ant Design Components]
        L[Tailwind CSS Utilities]
        J --> M[SLOIndicator Component];
        K & L -- Used by --> M
    end
    
    M --> N[Developer/PM];

方案的局限性与未来迭代

这个方案有效地解决了我们最初的“黑箱”问题,但它并非完美。

首先,Pushgateway 在设计上并非用于替代常规的指标采集,它可能成为一个单点故障和性能瓶颈。对于大规模应用,更好的方式可能是评估支持 Push 模式的监控后端,或者在边缘网络部署一个聚合器代理。

其次,客户端指标上报存在固有的不可靠性。如果用户在指标推送前强制关闭应用,或者处于离线状态,这部分数据就会丢失。虽然我们的批量和定时推送策略能缓解一部分问题,但无法根除。因此,客户端SLO数据应被视为趋势和代表性样本,而非100%精确的会计账簿,在决策时需牢记这一点。

未来的迭代方向很明确:一是将这个模式扩展到更多关键用户旅程,如支付成功率、图片加载成功率等,形成一套完整的移动端 SLO 指标体系;二是在错误预算消耗过快时,通过 webhook 自动触发告警,甚至连接到 CI/CD 系统,暂时冻结新的功能发布,实现真正的“基于错误预算的发布决策”。


  目录