生产环境致命错误你还在等客户反馈吗?分享一下致命错误的自动告警方案

最近换了工作,在周末时发现群里有客户反应有bug,弹窗是一个md5():Passing null to parameter #1 ($string) of type string is deprecated 的报错提示,很明显这里是参数类型的致命错误,这里对于我们的程序来说暴露出两个问题

  1. 这种致命错误在生产环境不应该直接显示错误信息,尤其是数据库错误,比如说哪个字段写错了哪个表写错了,是不应该直接暴露这种私密错误信息给用户的
  2. 没有一个及时告警的机制,出问题全靠用户反馈,要是用户懒不反馈呢?

这里以laravel框架为例,基于以上两点问题这里分享以下我自己的方案,主要思想分两个步骤:

其实thinkphp Yii 这些框架都一样的,只是一个基于异常处理器的使用思想

  • 实现即时通讯的提醒,这里选择基于webhook的钉钉群(也可以是企业微信群、飞书群),甚至是邮件、短信(当然这可能涉及成本)。钉钉/企微/飞书都是免费的,而企微和飞书都是需要加入企业才能使用,你们公司用哪个就实现哪个,如果都没用就使用钉钉(因为钉钉可以不需要加入企业就能使用),拉个群添加webhook群机器人,然后将项目负责开发拉进去
  • 注册实现异常处理器的报告,结合即时通讯实时告警

开撸!

App\Exceptions\Handlerregister方法默认就给我们留了注册如何报告异常的口子,实现这个方法用于及时告警,直接上代码

<?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 . "&timestamp=" . $timestamp . "&sign=" . urlencode(base64_encode($signData));
    }
}

还剩一个问题,很简单,继续实现App\Exceptions\Handlerrender方法,加入三行代码

这种致命错误在生产环境不应该直接显示错误信息,尤其是数据库错误,比如说哪个字段写错了哪个表写错了,是不应该直接暴露这种私密错误信息给用户的

<?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条消息

附上钉钉群的操作流程

  1. 可以用手机端的面对面建群实现只有自己一个人的群聊,这样方便调试
  2. 群设置添加机器人,选择自定义的webhook形式
    生产环境致命错误你还在等客户反馈吗?分享一下致命错误的自动告警方案
  3. 安全设置必须选一个,这里我选择的是加签
    生产环境致命错误你还在等客户反馈吗?分享一下致命错误的自动告警方案
  4. 记下你的secret和机器人地址,加入代码配置

最后

这个思想或者说方案适用于所有项目,在程序异常面前一定不能被动。及时发现及时解决,早一秒解决就减少一秒的损失。以上就一个十来行代码的改动实现了:在触发致命错误时对外隐藏私密信息,对内及时告警详细信息:heavy_check_mark::heavy_check_mark::heavy_check_mark:

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
讨论数量: 8

我也有这个需求。考虑过 Sentry 和 Flare 这样专业的服务,但费用偏高,大陆访问互联网常不靠谱。还不如 OP 这样自制呢,总比啥都没有强,

我了解的方案是基于 Laravel Logging 机制的,比如增加一个钉钉 Channel,发送日志过去。没想到直接处理异常,后面有机会都试一下。

2个月前 评论
yalng (楼主) 2个月前

直接用bugsnag啊,很多bug都是和用户相关的,你应该记录下登录信息

2个月前 评论
yalng (楼主) 2个月前
sanders

提一点小建议:

不要立即调用 webhook :调用开销和延迟会在错误大规模爆发的时候雪上加霜,影响正常的服务。可以通过定时任务扫描错误日志文件来进行收集和报告,这样也避免对钉钉用户造成消息轰炸。如果再完善一点就是可以对错误进行模式匹配进行一个统计,比如相同的错误消息统计一下5分钟内的数量,组合成一个消息传给钉钉。

2个月前 评论
yalng (楼主) 2个月前

可以试一下这个包

github.com/guanguans/laravel-excep...

已支持 30 多种报警通知渠道

discord lark mail
Laravel Laravel Laravel
2个月前 评论
yalng (楼主) 2个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!