PHP 应用的可观测性工程:别再当盲人摸象了,用 OpenTelemetry 和 Prometheus 疯狂输出
各位好。欢迎来到今天的“PHP 面具脱落”大会。
我是你们的讲师,一个在服务器日志堆里爬出来的资深老兵。今天我们不讲怎么把 Laravel 速度跑得飞快,也不讲怎么用 Swoole 把并发干到破万。今天我们要谈的是更“枯燥”但更“致命”的话题:当你的应用崩溃了,你怎么知道?
想象一下这个场景:凌晨三点,手机震动。老板发来一条微信:“用户反馈登录不了。”你从床上弹起来,打开浏览器,刷新两下,好着呢。你开始疯狂地 var_dump,你开始看 Nginx 日志,你开始查数据库连接池。最后,你发现是一个慢 SQL 查询把数据库堵死了。你改了代码,部署上线,第二天早上老板说:“昨天晚上那个事儿解决了吗?”你深吸一口气,心想:“当然,但这特么花了三个小时啊!”
这就是盲开车的后果。你手里没有仪表盘,没有 GPS,甚至没有后视镜,你就敢在高速公路上飙车。
今天,我们要安装一套完整的“智能驾驶系统”。我们将使用 OpenTelemetry (OTel) 作为中央神经系统,配合 Prometheus 作为仪表盘,彻底武装你的 PHP 应用。
准备好了吗?让我们把代码变成数据,把数据变成洞察。
第一部分:可观测性的三大支柱与 PHP 的“脆弱”体质
在动手之前,我们必须明确什么是可观测性。它不是监控,监控只是检查灯泡亮不亮;可观测性是检查引擎为什么会发出怪声。
可观测性由三块砖头组成:
- 指标: 数字。像速度表、转速表。比如:每秒请求数 (RPS),响应时间 (RT)。
- 日志: 叙述。像行车记录仪。比如:
User login failed: credentials invalid。 - 追踪: 拓扑。像全城地图。比如:请求 A -> 路由 B -> 查询数据库 C -> 调用外部 API D。
为什么 PHP 在这方面这么弱?
PHP 以前是那种“短期记忆障碍”患者。它一请求进来,main() 函数跑完,输出 HTML,然后它就自杀了,什么都没留下。它没有像 Java 或 Go 那样牢固的上下文传递机制。
但是,时代变了。PHP 7/8 早就不是当年的轮子了。我们要利用 OpenTelemetry,给这些“短期记忆障碍”患者装上“脑叶切除手术后的记忆增强芯片”。
第二部分:OpenTelemetry 入门——它是谁?它是啥?
OpenTelemetry (OTel) 不是一个库,它是一个协议标准,就像 HTTP。但如果你要监控,你得用它的 SDK。
对于 PHP 来说,我们要用 OpenTelemetry PHP SDK。它的架构非常优雅,就像乐高积木:
- API(接口): 告诉我们该做什么(
startSpan,recordMetric)。 - SDK(实现): 真正干活的东西(上报数据)。
- 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。
它的作用就像一个快递分拣中心:
- 接收 PHP 发来的二进制 OTLP 数据。
- 格式转换(把二进制变成 Prometheus 格式)。
- 上报给 Prometheus。
- 同时也可以把数据转发到 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 跑得比乌龟还慢!”
别慌。
性能开销分析:
- Metrics: 几乎可以忽略不计。
record方法本质上是把数据塞进一个数组,然后每 1 分钟(默认 batch interval)统一发送一次。对于高并发,这也就是纳秒级的开销。 - Tracing: 主要是 Context 传递的开销。目前 PHP 的实现基于 SAPI,开销很小。但如果你在循环里疯狂
startSpan,那就另当别论了。最佳实践:只在“业务边界”创建 Span。 不要在遍历数组时创建 Span,那会导致 Span 数量爆炸,拖死你的进程。
最佳实践清单:
- 不要滥用 Attribute: 在 Span 上加属性要慎重。比如不要加
user_id=123456(除非这是敏感数据且你确定要存)。Attribute 过多会导致内存占用飙升。通常加endpoint,method,db.system,http.status_code就够了。 - 设置采样率: 生产环境不需要 100% 采样。设置 10% 或 1% 就能捕捉到 90% 的问题。这能节省大量内存和带宽。
- 统一上下文: 确保你的 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。
(全场掌声,或者键盘敲击声)