生产环境致命错误你还在等客户反馈吗?分享一下致命错误的自动告警方案
最近换了工作,在周末时发现群里有客户反应有bug,弹窗是一个md5():Passing null to parameter #1 ($string) of type string is deprecated
的报错提示,很明显这里是参数类型的致命错误,这里对于我们的程序来说暴露出两个问题
- 这种致命错误在生产环境不应该直接显示错误信息,尤其是数据库错误,比如说哪个字段写错了哪个表写错了,是不应该直接暴露这种私密错误信息给用户的
- 没有一个及时告警的机制,出问题全靠用户反馈,要是用户懒不反馈呢?
这里以laravel
框架为例,基于以上两点问题这里分享以下我自己的方案,主要思想分两个步骤:
其实
thinkphp
Yii
这些框架都一样的,只是一个基于异常处理器的使用思想
- 实现即时通讯的提醒,这里选择基于
webhook
的钉钉群(也可以是企业微信群、飞书群),甚至是邮件、短信(当然这可能涉及成本)。钉钉/企微/飞书都是免费的,而企微和飞书都是需要加入企业才能使用,你们公司用哪个就实现哪个,如果都没用就使用钉钉(因为钉钉可以不需要加入企业就能使用),拉个群添加webhook群机器人,然后将项目负责开发拉进去 - 注册实现异常处理器的报告,结合即时通讯实时告警
开撸!
App\Exceptions\Handler
的register
方法默认就给我们留了注册如何报告异常的口子,实现这个方法用于及时告警,直接上代码
<?php
namespace App\Exceptions;
use App\Helpers\DingDingRobot;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\MassAssignmentException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\App;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* A list of the exception types that are force reported.
* @var array<int, class-string<Throwable>>
*/
protected array $forceReport = [
QueryException::class,
\ErrorException::class,
\Error::class,
\TypeError::class,
\BadMethodCallException::class,
BindingResolutionException::class,
MassAssignmentException::class,
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//生产环境下致命异常立刻告警到钉钉
if (App::isProduction() && $this->isForceReportException($e)) {
DingDingRobot::send('系统致命错误,速速检查!', [
'error' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine(),
'url' => request()?->fullUrl(),
'params' => request()?->all(),
// 'trace' => $e->getTraceAsString(),
]);
}
});
}
/**
* 判断当前异常是否为致命级异常
* @param Throwable $e
* @return bool
*/
protected function isForceReportException(Throwable $e): bool
{
foreach ($this->forceReport as $class) {
if ($e instanceof $class) {
return true;
}
}
return false;
}
}
以上代码主要是实现了register
方法,就几行代码就可以实现什么效果呢?效果:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class TestCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test';
/**
* Execute the console command.
*/
public function handle()
{
sdf;//乱写的致命错误
md5($this);//参数类型不匹配的致命错误
DB::table('ajsdasjd')->where('id', 1)->first(); //不存在的数据表致命异常
DB::table('ai_chat')->where('idasd', 1)->first(); //不存在的字段致命异常
}
}
ok,已经实现触发致命错误时实时告警,不再需要等用户反馈。再贴上上面引入的钉钉webhook群机器人实现代码
<?php
declare (strict_types=1);
namespace App\Helpers;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class DingDingRobot
{
public static function send(string $message, array $context = [])
{
try {
$url = env('EXCEPTION_ROBOT_URL');
$secret = env('EXCEPTION_ROBOT_SECRET');
if (is_null($url) || is_null($secret)) {
return;
}
$message .= ' ' . json_encode($context, JSON_UNESCAPED_UNICODE);
//过滤重复告警
if (!self::filterByCache($message)) {
return;
}
//异步请求钉钉发送
$promise = (new Client())
->postAsync(self::getSignUrl($url, $secret), [
'timeout' => 1,
'verify' => false,
'http_errors' => false,
'json' => [
'msgtype' => 'text',
'text' => [
'content' => '[' . config('app.env') . '] ' . $message
]
]
]);
$promise->wait();
} catch (\Throwable $exception) {
Log::error('钉钉告警异常:' . $exception->getMessage());
}
}
/**
* 过滤重复告警,钉钉限制每分钟最多发送20条告警
* @param string $message
* @return bool
* @throws \RedisException
*/
private static function filterByCache(string $message): bool
{
$key = 'exception-robot-limiter:' . date('YmdHi');
/** @var \Redis $redis */
$redis = Redis::client();
if ($redis->sCard($key) > 18 || $redis->sIsMember($key, $message)) {
return false;
}
$redis->sAdd($key, $message);
$redis->expire($key, 240);
return true;
}
/**
* 获取签名url
* @param string $url
* @param string $secret
* @return string
*/
private static function getSignUrl(string $url, string $secret): string
{
$timestamp = round(microtime(true) * 1000);
$stringToSign = $timestamp . "\n" . $secret;
$signData = hash_hmac('sha256', $stringToSign, $secret, true);
return $url . "×tamp=" . $timestamp . "&sign=" . urlencode(base64_encode($signData));
}
}
还剩一个问题,很简单,继续实现App\Exceptions\Handler
的render
方法,加入三行代码
这种致命错误在生产环境不应该直接显示错误信息,尤其是数据库错误,比如说哪个字段写错了哪个表写错了,是不应该直接暴露这种私密错误信息给用户的
<?php
namespace App\Exceptions;
use App\Helpers\DingDingRobot;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\MassAssignmentException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\App;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* A list of the exception types that are force reported.
* @var array<int, class-string<Throwable>>
*/
protected array $forceReport = [
QueryException::class,
\ErrorException::class,
\Error::class,
\TypeError::class,
\BadMethodCallException::class,
BindingResolutionException::class,
MassAssignmentException::class,
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//生产环境下致命异常立刻告警到钉钉
if (App::isProduction() && $this->isForceReportException($e)) {
DingDingRobot::send('系统致命错误,速速检查!', [
'error' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine(),
'url' => request()?->fullUrl(),
'params' => request()?->all(),
// 'trace' => $e->getTraceAsString(),
]);
}
});
}
public function render($request, Throwable $e)
{
//非调试模式下,致命异常隐藏私密信息,返回友好提示
if (!config('app.debug') && $this->isForceReportException($e)) {
return response()->json([
'code' => $e->getCode(),
'message' => '服务器开小差了,请稍后再试'
]);
}
return parent::render($request, $e);
}
/**
* 判断当前异常是否为致命级异常
* @param Throwable $e
* @return bool
*/
protected function isForceReportException(Throwable $e): bool
{
foreach ($this->forceReport as $class) {
if ($e instanceof $class) {
return true;
}
}
return false;
}
}
企微/钉钉/飞书
- 企微:必须加入企业才能使用,单次的消息长度有限制,次数不限制
- 飞书:必须加入企业才能使用,单次的消息长度有限制,次数不限制
- 钉钉:不用加入企业就能使用,每分钟限制最多20条消息
附上钉钉群的操作流程
- 可以用手机端的面对面建群实现只有自己一个人的群聊,这样方便调试
- 群设置添加机器人,选择自定义的webhook形式
- 安全设置必须选一个,这里我选择的是加签
- 记下你的secret和机器人地址,加入代码配置
最后
这个思想或者说方案适用于所有项目,在程序异常面前一定不能被动。及时发现及时解决,早一秒解决就减少一秒的损失。以上就一个十来行代码的改动实现了:在触发致命错误时对外隐藏私密信息,对内及时告警详细信息
本作品采用《CC 协议》,转载必须注明作者和本文链接
我也有这个需求。考虑过 Sentry 和 Flare 这样专业的服务,但费用偏高,大陆访问互联网常不靠谱。还不如 OP 这样自制呢,总比啥都没有强,
我了解的方案是基于 Laravel Logging 机制的,比如增加一个钉钉 Channel,发送日志过去。没想到直接处理异常,后面有机会都试一下。
直接用bugsnag啊,很多bug都是和用户相关的,你应该记录下登录信息
提一点小建议:
不要立即调用 webhook :调用开销和延迟会在错误大规模爆发的时候雪上加霜,影响正常的服务。可以通过定时任务扫描错误日志文件来进行收集和报告,这样也避免对钉钉用户造成消息轰炸。如果再完善一点就是可以对错误进行模式匹配进行一个统计,比如相同的错误消息统计一下5分钟内的数量,组合成一个消息传给钉钉。
可以试一下这个包
github.com/guanguans/laravel-excep...
已支持 30 多种报警通知渠道