PHP 应用的可观测性工程:利用 OpenTelemetry 实现 PHP 全链路请求追踪与 Prometheus 性能指标监控

PHP 应用的可观测性工程:别再当盲人摸象了,用 OpenTelemetry 和 Prometheus 疯狂输出

各位好。欢迎来到今天的“PHP 面具脱落”大会。

我是你们的讲师,一个在服务器日志堆里爬出来的资深老兵。今天我们不讲怎么把 Laravel 速度跑得飞快,也不讲怎么用 Swoole 把并发干到破万。今天我们要谈的是更“枯燥”但更“致命”的话题:当你的应用崩溃了,你怎么知道?

想象一下这个场景:凌晨三点,手机震动。老板发来一条微信:“用户反馈登录不了。”你从床上弹起来,打开浏览器,刷新两下,好着呢。你开始疯狂地 var_dump,你开始看 Nginx 日志,你开始查数据库连接池。最后,你发现是一个慢 SQL 查询把数据库堵死了。你改了代码,部署上线,第二天早上老板说:“昨天晚上那个事儿解决了吗?”你深吸一口气,心想:“当然,但这特么花了三个小时啊!”

这就是盲开车的后果。你手里没有仪表盘,没有 GPS,甚至没有后视镜,你就敢在高速公路上飙车。

今天,我们要安装一套完整的“智能驾驶系统”。我们将使用 OpenTelemetry (OTel) 作为中央神经系统,配合 Prometheus 作为仪表盘,彻底武装你的 PHP 应用。

准备好了吗?让我们把代码变成数据,把数据变成洞察。


第一部分:可观测性的三大支柱与 PHP 的“脆弱”体质

在动手之前,我们必须明确什么是可观测性。它不是监控,监控只是检查灯泡亮不亮;可观测性是检查引擎为什么会发出怪声。

可观测性由三块砖头组成:

  1. 指标: 数字。像速度表、转速表。比如:每秒请求数 (RPS),响应时间 (RT)。
  2. 日志: 叙述。像行车记录仪。比如:User login failed: credentials invalid
  3. 追踪: 拓扑。像全城地图。比如:请求 A -> 路由 B -> 查询数据库 C -> 调用外部 API D。

为什么 PHP 在这方面这么弱?

PHP 以前是那种“短期记忆障碍”患者。它一请求进来,main() 函数跑完,输出 HTML,然后它就自杀了,什么都没留下。它没有像 Java 或 Go 那样牢固的上下文传递机制。

但是,时代变了。PHP 7/8 早就不是当年的轮子了。我们要利用 OpenTelemetry,给这些“短期记忆障碍”患者装上“脑叶切除手术后的记忆增强芯片”。


第二部分:OpenTelemetry 入门——它是谁?它是啥?

OpenTelemetry (OTel) 不是一个库,它是一个协议标准,就像 HTTP。但如果你要监控,你得用它的 SDK。

对于 PHP 来说,我们要用 OpenTelemetry PHP SDK。它的架构非常优雅,就像乐高积木:

  1. API(接口): 告诉我们该做什么(startSpan, recordMetric)。
  2. SDK(实现): 真正干活的东西(上报数据)。
  3. Propagators(传播器): 搞“中间人”的。负责把 Trace ID 从一个服务传给另一个服务。

安装:给 PHP 打补丁

首先,你需要把补丁打上。别担心,不是给你脸上打补丁,是给你代码里打。

composer require open-telemetry/opentelemetry
composer require open-telemetry/sdk
composer require open-telemetry/exporter-otlp

注意,OpenTelemetry 的导出器非常灵活。我们这里选用 OTLP(OpenTelemetry Protocol),因为它可以无缝对接 Prometheus。


第三部分:实战——让 PHP 说话(指标 Metrics)

让我们先从最直观的指标开始。如果你不知道你的 API 跑得有多快,你怎么优化?

OpenTelemetry 把指标分得很细,但对我们来说,最重要的是 Counter(计数器)Histogram(直方图)

  • Counter: 增加的值(比如请求数)。
  • Histogram: 波动的值(比如响应时间,5ms, 50ms, 500ms)。

代码示例:在 Laravel 中集成 Metrics

我们通常在 Laravel 的中间件或者 Kernel.php 里初始化 Meter。这就像是给你的应用装上一个不停转动的轮子。

// app/Providers/AppServiceProvider.php
<?php

namespace AppProviders;

use IlluminateSupportServiceProvider;
use OpenTelemetrySDKMetricsMeterProvider;
use OpenTelemetrySDKCommonInstrumentationInstrumentationScope;
use OpenTelemetrySDKMetricsStableMetricsProvider;
use OpenTelemetrySDKResourceResourceInfo;
use OpenTelemetrySDKMetricsView;
use OpenTelemetrySDKMetricsDataTemporality;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // 1. 定义你的资源信息:这台服务器叫啥,跑在哪个容器里
        $resource = ResourceInfo::create([
            'service.name' => 'my-awesome-php-api',
            'service.version' => '1.0.0',
            'deployment.environment' => 'production'
        ]);

        // 2. 创建 MeterProvider
        // 这就是你的指标工厂
        $meterProvider = (new StableMetricsProvider())
            ->setResource($resource)
            ->addView(
                View::create()
                    .withName('response_time_seconds')
                    .withAttributeKeys(['endpoint', 'method'])
                    .withAttributeFilter(fn ($k, $v) => in_array($k, ['endpoint', 'method']))
                    .withAggregation(ViewAggregation::explicit_bucket_histogram([
                        0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10
                    ]))
            )
            ->build();

        // 3. 设置全局 MeterProvider
        // 这样你的代码里到处都能拿到它
        OpenTelemetrySDKMetricsMeterProvider::setDefault($meterProvider);
    }

    public function boot(): void
    {
        // 在这里我们可以初始化 Meter
        $meter = OpenTelemetrySDKMetricsMeterProvider::getDefault()->getMeter('app-metrics');

        // 创建一个 Counter,用来数有多少请求进来了
        $requests = $meter->createCounter('http.requests.total', 'Total number of HTTP requests', '1');

        // 创建一个 Histogram,用来记录响应时间
        $latency = $meter->createHistogram('http.request.latency', 'Request latency in seconds', 'seconds');
    }
}

看这段代码,是不是很高级?我们在定义一个 Histogram 时,定义了分桶。这意味着我们不只要看平均响应时间,还要看有多少请求卡在了 0.5 秒(可能是一个慢查询)。

接下来,我们在一个实际的 Controller 里使用它们。

// app/Http/Controllers/UserController.php
<?php

namespace AppHttpControllers;

use AppHttpControllersController;
use IlluminateHttpRequest;
use OpenTelemetrySDKMetricsMeterProvider;

class UserController extends Controller
{
    public function index(Request $request)
    {
        // 获取全局的 Meter
        $meter = MeterProvider::getDefault()->getMeter('app-metrics');

        // 获取我们在上面定义的 Histogram
        $latency = $meter->getHistogram('http.request.latency');
        // 获取 Counter
        $requests = $meter->getCounter('http.requests.total');

        // 开始记录时间
        $startTime = microtime(true);

        // --- 业务逻辑开始 ---
        // 模拟一些耗时操作
        sleep(1); 
        // --- 业务逻辑结束 ---

        $endTime = microtime(true);
        $duration = $endTime - $startTime;

        // 1. 记录计数器:这是一个 GET 请求,URL 是 /users
        $requests->add(
            1, 
            [
                'method' => $request->method(),
                'endpoint' => $request->path(),
                'status' => 200
            ]
        );

        // 2. 记录直方图:这次请求花了 1.0 秒
        $latency->record(
            $duration,
            [
                'method' => $request->method(),
                'endpoint' => $request->path(),
                'status' => 200
            ]
        );

        return response()->json(['message' => 'Hello World']);
    }
}

注意到了吗?$latency->record 调用中,我们传入了属性(Attributes)。这非常重要!这意味着当你去 Prometheus 里看图表时,你可以按 endpoint 过滤,只看 /api/login 的性能,而忽略掉 /api/static/js/main.js 的波动。


第四部分:实战——全链路追踪——侦探游戏

指标告诉你“慢了”,但追踪告诉你“哪里慢”。这就像是侦探看到尸体倒下了(慢了),但追踪告诉你,凶手是谁(是哪个数据库查询,哪个第三方 API)。

在分布式系统中,一个 HTTP 请求可能会经过:Nginx -> PHP -> Redis -> MySQL -> 外部支付网关。

我们需要把这条链路串起来。

核心概念:Span 和 Trace

  • Trace: 一条完整的路径(Trace ID)。
  • Span: 路径上的一个节点(Span ID)。

代码示例:手动创建 Span

在纯 PHP 环境下,我们需要手动创建 Span。但在 Laravel 这种魔改框架里,我们可以利用中间件。

// app/Http/Middleware/TraceMiddleware.php
<?php

namespace AppHttpMiddleware;

use Closure;
use IlluminateHttpRequest;
use OpenTelemetrySDKTraceTracerProvider;
use OpenTelemetrySDKCommonAttributesAttributes;

class TraceMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        // 1. 获取 Tracer
        $tracer = TracerProvider::getDefault()->getTracer('app-tracing');

        // 2. 创建 Span
        // 这就像是在代码里打了个桩,记录下这一段的时间
        $scope = $tracer->startActiveSpan('http.request');

        try {
            // 3. 设置 Span 的元数据
            $scope->getSpan()->setAttribute('http.method', $request->method());
            $scope->getSpan()->setAttribute('http.url', $request->fullUrl());

            // 4. 继续处理请求
            $response = $next($request);

            // 5. 记录状态码
            $scope->getSpan()->setAttribute('http.status_code', $response->getStatusCode());

            return $response;
        } catch (Exception $e) {
            // 6. 如果出错了,记录错误
            $scope->getSpan()->recordException($e);
            $scope->getSpan()->setStatus(OpenTelemetrySDKCommonTraceStatus::ok()); // 这里其实应该设为 error,简化演示

            throw $e;
        } finally {
            // 7. 关闭 Span(必须做!否则时间不会停止)
            $scope->close();
        }
    }
}

真正的魔法:Propagation(上下文传递)

上面的代码只是记录了当前 Controller 的信息。但是,当你的 Controller 去调用 MySQL 时,那个数据库连接怎么知道它是在处理哪个 HTTP 请求呢?

这就需要 Context Propagation

我们需要在数据库查询之前,把当前的 Trace ID 喂给数据库驱动。

use OpenTelemetrySDKCommonPropagationMap propagator;
use OpenTelemetryPropagationContext;

// 假设我们在 Controller 里准备调用 DB
$tracer = TracerProvider::getDefault()->getTracer('app-tracing');
$span = $tracer->startActiveSpan('db.query.select_user');
$context = Context::getCurrent()->with($span);

// 提取 HTTP 头中的 Trace ID
$headers = []; // 假设这是从 $request->headers->all() 来的
$propagator->inject($context, $headers);

// 现在的 Context 包含了 Trace ID 和 Span ID
// 我们需要把这个 Context 传给数据库连接(以 PDO 为例)
// 注意:这是伪代码,实际需要修改 PDO 的 connectOptions
$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    // 这里通常需要扩展 PDO 的驱动或使用中间件拦截 query 事件
    // 实际上 OpenTelemetry PHP SDK 提供了自动挂载
]);

// ...
$span->end();

等等,手动 Inject 太麻烦了,PHP 生态里有人帮我们干活。

其实,OpenTelemetry PHP SDK 提供了很强大的自动挂载功能。只要你安装了 open-telemetry/propagation-http,在 Laravel 中,你可以利用中间件自动把 HTTP 头(如 traceparent)解析出来,并附加到当前 Context 上。

这意味着,你只需要在你的中间件里写这么几行,剩下的血汗活 SDK 都替你干了:

// app/Http/Middleware/OtTraceMiddleware.php
namespace AppHttpMiddleware;

use Closure;
use OpenTelemetryPropagationContext propagation;
use OpenTelemetryPropagationTextMapPropagator propagator;
use OpenTelemetrySDKCommonContext as Context;

class OtTraceMiddleware
{
    public function handle($request, Closure $next)
    {
        // 1. 从 HTTP 头中提取 Context
        $carrier = new class($request->headers) implements OpenTelemetryPropagationTextMapInterface {
            private $headers;
            public function __construct($headers) { $this->headers = $headers; }
            public function get(string $key): ?string { return $this->headers->get($key); }
            public function set(string $key, string $value): void {}
        };

        $context = propagator->extract(Context::getCurrent(), $carrier);

        // 2. 把 Context 设为当前 Context
        // 这样下面的代码就能自动继承这个 Trace 了
        Context::with($context, function() use ($request, $next) {
            return $next($request);
        });
    }
}

这样,你甚至在 SQL 慢查询日志里,或者在 Monolog 的日志里,都能看到完整的 Trace ID。


第五部分:OpenTelemetry Collector —— 神经中枢

你可能会问:“我有了指标和追踪,我该往哪儿存?”

千万别把数据直接塞给 Prometheus。Prometheus 的抓取是主动的、有间隔的,而且它只喜欢特定的格式。追踪数据也是二进制的 OTLP 格式。

这时候,我们需要一个中转站。这个中转站就是 OpenTelemetry Collector

它的作用就像一个快递分拣中心:

  1. 接收 PHP 发来的二进制 OTLP 数据。
  2. 格式转换(把二进制变成 Prometheus 格式)。
  3. 上报给 Prometheus。
  4. 同时也可以把数据转发到 Jaeger(看图)或 Loki(存日志)。

Collector 配置文件(Collector.yaml)

这是一个 YAML 配置,看起来可能有点吓人,但其实就是“管道”的配置。

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317 # PHP SDK 默认往这儿发
      http:
        endpoint: 0.0.0.0:4318

exporters:
  prometheusremotewrite:
    endpoint: "prometheus:9090/api/v1/write" # 喂给 Prometheus

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [prometheusremotewrite]
    traces:
      receivers: [otlp]
      exporters: [prometheusremotewrite] # 如果你有 Jaeger,可以把这里改成 exporter: [jaeger]

配置起来非常简单。这就好比你在厨房做饭(PHP),把菜端到后厨(Collector),后厨自动切菜、装盘(Prometheus 格式化),然后端给你(Prometheus)。


第六部分:Prometheus 与 Grafana —— 仪表盘

现在,一切准备就绪。让我们来看看怎么“看”数据。

1. Prometheus 配置

你需要告诉 Prometheus 去“抓”数据。当然,因为我们用了 Collector,Prometheus 其实只需要抓 Collector。

scrape_configs:
  - job_name: 'php-app-otel-collector'
    static_configs:
      - targets: ['otel-collector:4317']

2. 构建查询

假设我们在代码里记录了一个 Histogram 叫 http.request.latency。我们想知道哪些接口响应时间最长。

PromQL (Prometheus Query Language) 就是我们的放大镜。

# 1. 找出所有 API 请求的响应时间分布
histogram_quantile(0.95, sum(rate(http_request_latency_bucket{service="my-awesome-php-api"}[5m])) by (le, endpoint))

# 解释一下:
# histogram_quantile(0.95, ...) : 找出第 95 分位的响应时间。也就是 95% 的请求都比这个时间快。
# sum(rate(...[5m])) : 过去 5 分钟内每秒请求量的总和。
# by (le, endpoint) : 按照桶的等级和接口路径分组。

在 Grafana 中,你可以画一个这样的图:

  • X 轴:时间。
  • Y 轴:时间(毫秒)。
  • 阈值线:设为 500ms。
  • 当你的曲线超过 500ms 并报警,你就知道:嘿,这帮家伙又卡住了!

3. 链路追踪可视化

如果你配置了 Collector 的 Jaeger 导出器,你可以打开 Jaeger UI。
你点击一个 Trace ID(或者搜索 my-awesome-php-api)。
你会看到一条金色的线:

  • http.request (0ms) 开始。
  • 到达 db.query.select_user (50ms)。
  • 到达 external.payment_api (480ms)。

你看! 问题一目了然!不是你的 PHP 代码慢,是第三方支付接口慢!这就叫“甩锅”,也叫做“精准定位”。


第七部分:进阶——性能开销与最佳实践

这时候,肯定有技术大拿要跳出来:“兄弟,加这玩意儿,我的 PHP 跑得比乌龟还慢!”

别慌。

性能开销分析:

  1. Metrics: 几乎可以忽略不计。record 方法本质上是把数据塞进一个数组,然后每 1 分钟(默认 batch interval)统一发送一次。对于高并发,这也就是纳秒级的开销。
  2. Tracing: 主要是 Context 传递的开销。目前 PHP 的实现基于 SAPI,开销很小。但如果你在循环里疯狂 startSpan,那就另当别论了。最佳实践:只在“业务边界”创建 Span。 不要在遍历数组时创建 Span,那会导致 Span 数量爆炸,拖死你的进程。

最佳实践清单:

  1. 不要滥用 Attribute: 在 Span 上加属性要慎重。比如不要加 user_id=123456(除非这是敏感数据且你确定要存)。Attribute 过多会导致内存占用飙升。通常加 endpoint, method, db.system, http.status_code 就够了。
  2. 设置采样率: 生产环境不需要 100% 采样。设置 10% 或 1% 就能捕捉到 90% 的问题。这能节省大量内存和带宽。
  3. 统一上下文: 确保你的 Monolog Logger 也支持 Context。这样你的日志里能直接打印出 Trace ID,搜索日志时一搜到底。

如何设置采样率?

$tracerProvider = TracerProvider::getDefault();
$sampler = new OpenTelemetrySDKTraceSamplersProbabilitySampler(0.1); // 10%
$tracerProvider->getProcessor()->setSampler($sampler);

结语:从“能跑”到“卓越”

好了,今天的讲座接近尾声。

让我们回顾一下。我们给 PHP 应用装上了 OpenTelemetry,它就像一个不知疲倦的录音笔。我们把请求的每一个动作都录了下来(Metrics),把每一条路径都画了下来(Tracing)。

以前,我们面对生产环境的报错,只能靠猜。
现在,我们面对的是实实在在的数据图表。

当你看到 Prometheus 里的曲线突然飙升,报警响起,你不再恐慌。你点开 Jaeger,看到瓶颈在于 queue_consumer.php,或者看到是 third_party_api 卡住了。你修改代码,部署,观察曲线回落。

这就是可观测性工程的力量。

它不是花哨的新技术,它是现代软件工程的基石。它是你深夜对着黑屏屏幕时,唯一能给你慰藉的伙伴。它让你从“救火队员”变成了“架构师”。

下次,当你又要去生产环境 tail -f 日志的时候,先想想:有没有更好的办法?有没有更早发现问题的办法?

现在,打开你的终端,运行 composer require open-telemetry/opentelemetry。给你的 PHP 加上这双“千里眼”吧。

祝你们的服务器都跑得飞快,且永远没有 Bug。

(全场掌声,或者键盘敲击声)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注