应封装 error_log() 配合 debug_backtrace() 和白名单变量提取,记录带 request id、关键上下文与异常堆栈的日志,避免敏感信息泄露和性能损耗。

用 error_log() 记录带上下文的错误信息
PHP 原生的 error_log() 本身不自动捕获变量或调用栈,但配合 debug_backtrace() 和 get_defined_vars() 就能补全上下文。关键不是“记不记”,而是“记哪些、怎么记才不拖慢请求又不丢关键现场”。
常见错误现象:error_log("failed") 这种写法在生产环境几乎没用——你不知道是哪个用户、哪个请求参数、哪一行触发的失败。
- 只记录错误消息,不记录
$_GET、$_POST、$id等关键局部变量,排查时得靠猜 - 直接
print_r($vars)进日志,可能把敏感字段(如密码、token)打进去,也容易撑爆日志文件 - 在循环里反复调用
debug_backtrace(),性能明显下降(尤其深度 >5 的调用栈)
实操建议:封装一个轻量函数,按需裁剪上下文:
function log_with_context($message, $context = []) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
$safe_vars = array_intersect_key(get_defined_vars(), ['id' => 1, 'user_id' => 1, 'action' => 1]);
$log_entry = json_encode([
'msg' => $message,
'time' => date('c'),
'file' => $trace[0]['file'] ?? '',
'line' => $trace[0]['line'] ?? '',
'context' => array_merge($safe_vars, $context),
]);
error_log($log_entry);
}
捕获未处理异常时保留完整上下文
全局异常处理器(set_exception_handler())是补救的最后一道防线,但它默认收不到当前作用域变量。光靠 $e->getTraceAsString() 只能看到调用路径,看不到出错前 $data 长什么样。
立即学习“PHP免费学习笔记(深入)”;
使用场景:API 接口抛出 InvalidArgumentException,但前端只报“500”,后端日志里没有输入数据快照。
-
set_exception_handler()回调函数运行在新作用域,get_defined_vars()拿不到原出错位置的变量 - 试图在
try/catch里手动收集上下文,但漏掉未被try包裹的代码路径 - 用
register_shutdown_function()捕获致命错误时,debug_backtrace()返回空数组(PHP 限制)
实操建议:在关键入口(如框架中间件、路由分发前)主动快照一次上下文,存到静态变量里,异常处理器读取它:
static $request_context = [];
$request_context = [
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'input' => json_decode(file_get_contents('php://input'), true) ?: $_POST,
];
set_exception_handler(function($e) use ($request_context) {
error_log(json_encode([
'exception' => $e->getMessage(),
'trace' => $e->getTrace(),
'context' => $request_context,
]));
});
用 Monolog 时避免上下文爆炸和敏感泄露
很多人以为用了 Monolog 就自动有上下文,其实默认只记录 context 数组里的内容——而这个数组如果直接传 $_REQUEST 或 $this,日志体积会暴涨,且极易泄露敏感字段。
性能影响:对大对象(如 ORM 实体、上传的 $_FILES)做 var_export() 或递归 JSON 编码,CPU 和内存开销显著上升。
- 配置
Monolog\Handler\StreamHandler时没设maxFiles,日志滚动生成失控 - 把整个
$pdo实例塞进 context,序列化时报错或卡死 - 用
__toString()或jsonSerialize()自定义序列化逻辑,但没处理循环引用
实操建议:始终用白名单过滤 + 类型检查:
$logger->error('DB insert failed', [
'table' => $table,
'params' => array_filter($params, function($v) {
return !is_object($v) && !is_resource($v);
}),
'user_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
]);
日志中还原请求唯一标识(Request ID)
没有 Request ID 的上下文日志,在并发请求下根本无法归因——你看到 5 条“user not found”日志,但不知道它们是否来自同一个前端操作链。
容易踩的坑:用 microtime(true) 或 uniqid() 在不同中间件里重复生成,导致同一请求在不同日志行里 ID 不一致;或者把 ID 存在 $_SERVER 里但没透传到子进程/异步任务。
- CLI 脚本和 Web 请求共用同一套日志逻辑,但 CLI 没有
$_SERVER['REQUEST_ID'],直接取会告警 - 用
opcache.enable_cli=1时,uniqid('', true)在短时间多次调用可能碰撞 - 微服务间通过 HTTP header 透传 ID,但没在日志格式里显式输出,查日志时还得手动 grep
实操建议:在请求最开始统一生成并注入,优先用环境变量或全局静态属性:
if (!isset($_SERVER['REQUEST_ID'])) {
$_SERVER['REQUEST_ID'] = sprintf('%s-%s', date('YmdHis'), substr(md5(uniqid('', true)), 0, 8));
}
// 后续所有日志都带上
error_log(json_encode(['req_id' => $_SERVER['REQUEST_ID'], 'msg' => '...']));
复杂点在于跨进程——比如用 proc_open() 调外部命令,或投递队列任务,这时候 Request ID 必须显式传递,不能依赖全局状态。这点很容易被忽略,一查日志就断链。











