Showing
6 changed files
with
586 additions
and
1 deletions
| ... | @@ -257,6 +257,93 @@ DAITO_GOOGLE_CHAT_VERIFY_SSL=false | ... | @@ -257,6 +257,93 @@ DAITO_GOOGLE_CHAT_VERIFY_SSL=false |
| 257 | 257 | ||
| 258 | Use this only for temporary debugging on local environment. Never disable SSL verification on production. | 258 | Use this only for temporary debugging on local environment. Never disable SSL verification on production. |
| 259 | 259 | ||
| 260 | +## DaitoExceptionNotifier (separate reusable module) | ||
| 261 | + | ||
| 262 | +### 1) Publish config | ||
| 263 | +```bash | ||
| 264 | +php artisan vendor:publish --tag=daito-exception-notifier-config | ||
| 265 | +``` | ||
| 266 | + | ||
| 267 | +This creates `config/daito-exception-notifier.php`. | ||
| 268 | + | ||
| 269 | +### 2) Minimal `.env` setup | ||
| 270 | +```dotenv | ||
| 271 | +DAITO_EXCEPTION_NOTIFIER_ENABLED=true | ||
| 272 | +DAITO_EXCEPTION_NOTIFIER_SEND_MODE=queue | ||
| 273 | +DAITO_EXCEPTION_NOTIFIER_LOOP_GUARD_ENABLED=true | ||
| 274 | +DAITO_EXCEPTION_NOTIFIER_LOOP_GUARD_TTL_SECONDS=30 | ||
| 275 | +DAITO_EXCEPTION_NOTIFIER_TRACE_MODE=smart | ||
| 276 | +DAITO_EXCEPTION_NOTIFIER_TRACE_MAX_LINES=8 | ||
| 277 | +DAITO_EXCEPTION_NOTIFIER_TRACE_SKIP_VENDOR=true | ||
| 278 | +DAITO_EXCEPTION_NOTIFIER_TRACE_INCLUDE_FIRST_APP_FRAME=true | ||
| 279 | +``` | ||
| 280 | + | ||
| 281 | +### 3) Auto notify on exception (HTTP + CLI) | ||
| 282 | + | ||
| 283 | +Use Laravel exception handler to automatically send exception cardV2: | ||
| 284 | + | ||
| 285 | +```php | ||
| 286 | +<?php | ||
| 287 | + | ||
| 288 | +namespace App\Exceptions; | ||
| 289 | + | ||
| 290 | +use Daito\Lib\DaitoExceptionNotifier; | ||
| 291 | +use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; | ||
| 292 | +use Throwable; | ||
| 293 | + | ||
| 294 | +class Handler extends ExceptionHandler | ||
| 295 | +{ | ||
| 296 | + public function register() | ||
| 297 | + { | ||
| 298 | + $this->reportable(function (Throwable $throwable) { | ||
| 299 | + DaitoExceptionNotifier::notify($throwable); | ||
| 300 | + }); | ||
| 301 | + } | ||
| 302 | +} | ||
| 303 | +``` | ||
| 304 | + | ||
| 305 | +`DaitoExceptionNotifier::notify()` supports both route/controller errors and CLI command errors because Laravel routes both through the same exception handler. | ||
| 306 | + | ||
| 307 | +If you want explicit mode: | ||
| 308 | + | ||
| 309 | +```php | ||
| 310 | +DaitoExceptionNotifier::queue($throwable); // queue | ||
| 311 | +DaitoExceptionNotifier::send($throwable); // sync immediate | ||
| 312 | +``` | ||
| 313 | + | ||
| 314 | +Exception cardV2 includes: | ||
| 315 | + | ||
| 316 | +- `time` | ||
| 317 | +- `action` | ||
| 318 | +- `file` | ||
| 319 | +- `line` | ||
| 320 | +- `message` | ||
| 321 | +- compact `trace` (top useful frames only, configurable) | ||
| 322 | +- `first_app_frame` (when available) | ||
| 323 | + | ||
| 324 | +Trace filter strategy (`DAITO_EXCEPTION_NOTIFIER_TRACE_MODE`): | ||
| 325 | + | ||
| 326 | +- `smart` (recommended): prefer app frames, then non-vendor frames, then fallback | ||
| 327 | +- `app_only`: only frames that belong to `app/` or class prefix `App\` | ||
| 328 | +- `no_vendor`: remove `vendor` frames | ||
| 329 | +- `class_prefix_only`: only frames by configured class prefixes (`trace_class_prefixes`) | ||
| 330 | + | ||
| 331 | +### 4) Built-in loop guard (recommended default) | ||
| 332 | + | ||
| 333 | +To avoid recursive notify loops when Google Chat send itself throws exceptions, this module has a built-in circuit-breaker: | ||
| 334 | + | ||
| 335 | +- re-entrant guard in the same process/request | ||
| 336 | +- short TTL dedupe by exception fingerprint (file+line+message+action) | ||
| 337 | +- optional cache-based dedupe across workers/processes | ||
| 338 | + | ||
| 339 | +Main config keys: | ||
| 340 | + | ||
| 341 | +- `loop_guard_enabled` | ||
| 342 | +- `loop_guard_ttl_seconds` | ||
| 343 | +- `loop_guard_use_cache` | ||
| 344 | +- `loop_guard_cache_prefix` | ||
| 345 | +- `loop_guard_skip_if_notifier_in_trace` | ||
| 346 | + | ||
| 260 | ## QueryLog (Laravel shared package) | 347 | ## QueryLog (Laravel shared package) |
| 261 | 348 | ||
| 262 | > **Provider registration note** | 349 | > **Provider registration note** | ... | ... |
| ... | @@ -21,7 +21,8 @@ | ... | @@ -21,7 +21,8 @@ |
| 21 | "laravel": { | 21 | "laravel": { |
| 22 | "providers": [ | 22 | "providers": [ |
| 23 | "Daito\\Lib\\DaitoQueryLog\\Providers\\DaitoQueryLogProvider", | 23 | "Daito\\Lib\\DaitoQueryLog\\Providers\\DaitoQueryLogProvider", |
| 24 | - "Daito\\Lib\\DaitoGoogleChat\\Providers\\DaitoGoogleChatProvider" | 24 | + "Daito\\Lib\\DaitoGoogleChat\\Providers\\DaitoGoogleChatProvider", |
| 25 | + "Daito\\Lib\\DaitoExceptionNotifier\\Providers\\DaitoExceptionNotifierProvider" | ||
| 25 | ] | 26 | ] |
| 26 | } | 27 | } |
| 27 | } | 28 | } | ... | ... |
src/DaitoExceptionNotifier.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace Daito\Lib; | ||
| 4 | + | ||
| 5 | +use Daito\Lib\DaitoExceptionNotifier\DaitoExceptionNotifierManager; | ||
| 6 | +use RuntimeException; | ||
| 7 | +use Throwable; | ||
| 8 | + | ||
| 9 | +class DaitoExceptionNotifier | ||
| 10 | +{ | ||
| 11 | + public static function send(Throwable $throwable, array $arrContext = array(), $webhookUrl = null): array | ||
| 12 | + { | ||
| 13 | + return self::manager()->send($throwable, $arrContext, $webhookUrl); | ||
| 14 | + } | ||
| 15 | + | ||
| 16 | + public static function queue(Throwable $throwable, array $arrContext = array(), $webhookUrl = null): void | ||
| 17 | + { | ||
| 18 | + self::manager()->queue($throwable, $arrContext, $webhookUrl); | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + public static function notify(Throwable $throwable, array $arrContext = array(), $webhookUrl = null) | ||
| 22 | + { | ||
| 23 | + return self::manager()->notify($throwable, $arrContext, $webhookUrl); | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + private static function manager(): DaitoExceptionNotifierManager | ||
| 27 | + { | ||
| 28 | + if (!function_exists('app')) { | ||
| 29 | + throw new RuntimeException('Laravel app container is required for DaitoExceptionNotifier.'); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + $manager = app(DaitoExceptionNotifierManager::class); | ||
| 33 | + if (!$manager instanceof DaitoExceptionNotifierManager) { | ||
| 34 | + throw new RuntimeException('Can not resolve DaitoExceptionNotifierManager from container.'); | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + return $manager; | ||
| 38 | + } | ||
| 39 | +} |
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace Daito\Lib\DaitoExceptionNotifier; | ||
| 4 | + | ||
| 5 | +use Daito\Lib\DaitoGoogleChat\DaitoGoogleChatManager; | ||
| 6 | +use Throwable; | ||
| 7 | + | ||
| 8 | +class DaitoExceptionNotifierManager | ||
| 9 | +{ | ||
| 10 | + /** | ||
| 11 | + * Guard re-entrant notify in same process. | ||
| 12 | + * | ||
| 13 | + * @var bool | ||
| 14 | + */ | ||
| 15 | + private static $isNotifying = false; | ||
| 16 | + | ||
| 17 | + /** | ||
| 18 | + * Lightweight per-process cooldown by fingerprint. | ||
| 19 | + * | ||
| 20 | + * @var array<string, float> | ||
| 21 | + */ | ||
| 22 | + private static $arrFingerprintCooldowns = array(); | ||
| 23 | + | ||
| 24 | + /** | ||
| 25 | + * @var \Daito\Lib\DaitoGoogleChat\DaitoGoogleChatManager | ||
| 26 | + */ | ||
| 27 | + private $daitoGoogleChatManager; | ||
| 28 | + | ||
| 29 | + public function __construct(DaitoGoogleChatManager $daitoGoogleChatManager) | ||
| 30 | + { | ||
| 31 | + $this->daitoGoogleChatManager = $daitoGoogleChatManager; | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + public function send(Throwable $throwable, array $arrContext = array(), $webhookUrl = null): array | ||
| 35 | + { | ||
| 36 | + if (!(bool) config('daito-exception-notifier.enabled', true)) { | ||
| 37 | + return array( | ||
| 38 | + 'success' => 0, | ||
| 39 | + 'status' => 'disabled', | ||
| 40 | + ); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + return $this->daitoGoogleChatManager->sendCardV2( | ||
| 44 | + $this->buildCardV2($throwable, $arrContext), | ||
| 45 | + $webhookUrl | ||
| 46 | + ); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + public function queue(Throwable $throwable, array $arrContext = array(), $webhookUrl = null): void | ||
| 50 | + { | ||
| 51 | + if (!(bool) config('daito-exception-notifier.enabled', true)) { | ||
| 52 | + return; | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + $this->daitoGoogleChatManager->queueCardV2( | ||
| 56 | + $this->buildCardV2($throwable, $arrContext), | ||
| 57 | + $webhookUrl | ||
| 58 | + ); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + public function notify(Throwable $throwable, array $arrContext = array(), $webhookUrl = null) | ||
| 62 | + { | ||
| 63 | + if (!(bool) config('daito-exception-notifier.enabled', true)) { | ||
| 64 | + return array( | ||
| 65 | + 'success' => 0, | ||
| 66 | + 'status' => 'disabled', | ||
| 67 | + ); | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + if ($this->shouldBlockByLoopGuard($throwable, $arrContext)) { | ||
| 71 | + return array( | ||
| 72 | + 'success' => 0, | ||
| 73 | + 'status' => 'blocked_loop_guard', | ||
| 74 | + ); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + $mode = strtolower((string) config('daito-exception-notifier.send_mode', 'queue')); | ||
| 78 | + self::$isNotifying = true; | ||
| 79 | + try { | ||
| 80 | + if ($mode === 'sync' || $mode === 'immediate') { | ||
| 81 | + return $this->send($throwable, $arrContext, $webhookUrl); | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + $this->queue($throwable, $arrContext, $webhookUrl); | ||
| 85 | + | ||
| 86 | + return array( | ||
| 87 | + 'success' => 1, | ||
| 88 | + 'status' => 'queued', | ||
| 89 | + ); | ||
| 90 | + } finally { | ||
| 91 | + self::$isNotifying = false; | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + private function buildCardV2(Throwable $throwable, array $arrContext = array()): array | ||
| 96 | + { | ||
| 97 | + $action = isset($arrContext['action']) ? (string) $arrContext['action'] : $this->resolveCurrentAction(); | ||
| 98 | + $arrTraceData = $this->extractTraceData($throwable); | ||
| 99 | + $arrSummaryWidgets = array( | ||
| 100 | + $this->makeDecoratedTextWidget('time', date('Y-m-d H:i:s')), | ||
| 101 | + $this->makeDecoratedTextWidget('action', $action), | ||
| 102 | + $this->makeDecoratedTextWidget('file', (string) $throwable->getFile()), | ||
| 103 | + $this->makeDecoratedTextWidget('line', (string) $throwable->getLine()), | ||
| 104 | + $this->makeDecoratedTextWidget( | ||
| 105 | + 'message', | ||
| 106 | + $this->limitText((string) $throwable->getMessage(), (int) config('daito-exception-notifier.message_max_length', 1000)) | ||
| 107 | + ), | ||
| 108 | + ); | ||
| 109 | + | ||
| 110 | + if ((bool) config('daito-exception-notifier.trace_include_first_app_frame', true) | ||
| 111 | + && $arrTraceData['first_app_frame'] !== '' | ||
| 112 | + ) { | ||
| 113 | + $arrSummaryWidgets[] = $this->makeDecoratedTextWidget('first_app_frame', $arrTraceData['first_app_frame']); | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + return array( | ||
| 117 | + 'cardId' => 'daito-exception-alert', | ||
| 118 | + 'card' => array( | ||
| 119 | + 'header' => array( | ||
| 120 | + 'title' => (string) config('daito-exception-notifier.card_title', 'Exception Alert'), | ||
| 121 | + 'subtitle' => $action, | ||
| 122 | + ), | ||
| 123 | + 'sections' => array( | ||
| 124 | + array( | ||
| 125 | + 'header' => 'Summary', | ||
| 126 | + 'widgets' => $arrSummaryWidgets, | ||
| 127 | + ), | ||
| 128 | + array( | ||
| 129 | + 'header' => 'Trace', | ||
| 130 | + 'widgets' => array( | ||
| 131 | + array( | ||
| 132 | + 'textParagraph' => array( | ||
| 133 | + 'text' => $this->escapeForGoogleChat($arrTraceData['trace']), | ||
| 134 | + ), | ||
| 135 | + ), | ||
| 136 | + ), | ||
| 137 | + ), | ||
| 138 | + ), | ||
| 139 | + ), | ||
| 140 | + ); | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + private function extractTraceData(Throwable $throwable): array | ||
| 144 | + { | ||
| 145 | + $maxLines = max(1, (int) config('daito-exception-notifier.trace_max_lines', 8)); | ||
| 146 | + $maxLength = (int) config('daito-exception-notifier.trace_max_length', 2500); | ||
| 147 | + $traceMode = strtolower((string) config('daito-exception-notifier.trace_mode', 'smart')); | ||
| 148 | + $skipVendor = (bool) config('daito-exception-notifier.trace_skip_vendor', true); | ||
| 149 | + $arrTrace = (array) $throwable->getTrace(); | ||
| 150 | + $rootDir = function_exists('base_path') ? str_replace('\\', '/', base_path()) : ''; | ||
| 151 | + $appDir = function_exists('base_path') ? str_replace('\\', '/', base_path('app')) : ''; | ||
| 152 | + $arrClassPrefixes = (array) config('daito-exception-notifier.trace_class_prefixes', array('App\\')); | ||
| 153 | + $arrMappedFrames = array(); | ||
| 154 | + $arrAppFrames = array(); | ||
| 155 | + $arrNonVendorFrames = array(); | ||
| 156 | + | ||
| 157 | + foreach ($arrTrace as $index => $arrFrame) { | ||
| 158 | + $arrFrameInfo = $this->mapTraceFrame($arrFrame, $index); | ||
| 159 | + $arrMappedFrames[] = $arrFrameInfo; | ||
| 160 | + | ||
| 161 | + if ($this->isAppFrame($arrFrameInfo, $appDir, $arrClassPrefixes)) { | ||
| 162 | + $arrAppFrames[] = $arrFrameInfo; | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + if (!$this->isVendorPath($arrFrameInfo['file'], $rootDir)) { | ||
| 166 | + $arrNonVendorFrames[] = $arrFrameInfo; | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + $arrSelectedFrames = $this->selectTraceFramesByMode( | ||
| 171 | + $traceMode, | ||
| 172 | + $arrMappedFrames, | ||
| 173 | + $arrAppFrames, | ||
| 174 | + $arrNonVendorFrames, | ||
| 175 | + $skipVendor, | ||
| 176 | + $maxLines | ||
| 177 | + ); | ||
| 178 | + | ||
| 179 | + if ($arrSelectedFrames === array()) { | ||
| 180 | + $arrSelectedFrames = array_slice($arrMappedFrames, 0, $maxLines); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + $arrTraceLines = array(); | ||
| 184 | + foreach ($arrSelectedFrames as $arrFrameInfo) { | ||
| 185 | + $arrTraceLines[] = $arrFrameInfo['text']; | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + $traceText = $this->limitText(implode("\n", $arrTraceLines), $maxLength); | ||
| 189 | + $firstAppFrame = isset($arrAppFrames[0]) ? $arrAppFrames[0]['text'] : ''; | ||
| 190 | + | ||
| 191 | + return array( | ||
| 192 | + 'trace' => $traceText, | ||
| 193 | + 'first_app_frame' => $firstAppFrame, | ||
| 194 | + ); | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + private function mapTraceFrame(array $arrFrame, int $index): array | ||
| 198 | + { | ||
| 199 | + $file = isset($arrFrame['file']) ? (string) $arrFrame['file'] : ''; | ||
| 200 | + $line = isset($arrFrame['line']) ? (int) $arrFrame['line'] : 0; | ||
| 201 | + $class = isset($arrFrame['class']) ? (string) $arrFrame['class'] : ''; | ||
| 202 | + $type = isset($arrFrame['type']) ? (string) $arrFrame['type'] : ''; | ||
| 203 | + $function = isset($arrFrame['function']) ? (string) $arrFrame['function'] : 'unknown'; | ||
| 204 | + $text = sprintf('#%d %s%s%s %s:%d', $index, $class, $type, $function, $file !== '' ? $file : '[internal]', $line); | ||
| 205 | + | ||
| 206 | + return array( | ||
| 207 | + 'text' => $text, | ||
| 208 | + 'file' => $file, | ||
| 209 | + 'class' => $class, | ||
| 210 | + ); | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + private function isAppFrame(array $arrFrameInfo, string $appDir, array $arrClassPrefixes): bool | ||
| 214 | + { | ||
| 215 | + $file = (string) ($arrFrameInfo['file'] ?? ''); | ||
| 216 | + $class = (string) ($arrFrameInfo['class'] ?? ''); | ||
| 217 | + if ($file !== '' && $appDir !== '') { | ||
| 218 | + $normalizedFile = str_replace('\\', '/', $file); | ||
| 219 | + if (strpos($normalizedFile, $appDir . '/') === 0 || $normalizedFile === $appDir) { | ||
| 220 | + return true; | ||
| 221 | + } | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + foreach ($arrClassPrefixes as $prefix) { | ||
| 225 | + $prefixText = (string) $prefix; | ||
| 226 | + if ($prefixText !== '' && strpos($class, $prefixText) === 0) { | ||
| 227 | + return true; | ||
| 228 | + } | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + return false; | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + private function selectTraceFramesByMode( | ||
| 235 | + string $traceMode, | ||
| 236 | + array $arrMappedFrames, | ||
| 237 | + array $arrAppFrames, | ||
| 238 | + array $arrNonVendorFrames, | ||
| 239 | + bool $skipVendor, | ||
| 240 | + int $maxLines | ||
| 241 | + ): array { | ||
| 242 | + if ($traceMode === 'app_only') { | ||
| 243 | + return array_slice($arrAppFrames, 0, $maxLines); | ||
| 244 | + } | ||
| 245 | + | ||
| 246 | + if ($traceMode === 'no_vendor') { | ||
| 247 | + return array_slice($arrNonVendorFrames, 0, $maxLines); | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + if ($traceMode === 'class_prefix_only') { | ||
| 251 | + return array_slice($arrAppFrames, 0, $maxLines); | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + // smart mode: app frames -> non-vendor frames -> fallback all frames | ||
| 255 | + if ($arrAppFrames !== array()) { | ||
| 256 | + return array_slice($arrAppFrames, 0, $maxLines); | ||
| 257 | + } | ||
| 258 | + | ||
| 259 | + if ($skipVendor && $arrNonVendorFrames !== array()) { | ||
| 260 | + return array_slice($arrNonVendorFrames, 0, $maxLines); | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + return array_slice($arrMappedFrames, 0, $maxLines); | ||
| 264 | + } | ||
| 265 | + | ||
| 266 | + private function resolveCurrentAction(): string | ||
| 267 | + { | ||
| 268 | + if (function_exists('app') && app()->runningInConsole()) { | ||
| 269 | + $arrArgv = $_SERVER['argv'] ?? array(); | ||
| 270 | + return isset($arrArgv[1]) ? 'cli: ' . trim((string) $arrArgv[1]) : 'cli: console'; | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + if (!function_exists('request')) { | ||
| 274 | + return 'unknown'; | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + $request = request(); | ||
| 278 | + if ($request === null) { | ||
| 279 | + return 'http'; | ||
| 280 | + } | ||
| 281 | + | ||
| 282 | + return $request->method() . ' ' . $request->fullUrl(); | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + private function isVendorPath(string $path, string $rootDir): bool | ||
| 286 | + { | ||
| 287 | + if ($path === '') { | ||
| 288 | + return false; | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + $normalizedPath = str_replace('\\', '/', $path); | ||
| 292 | + if ($rootDir === '') { | ||
| 293 | + return strpos($normalizedPath, '/vendor/') !== false; | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + return strpos($normalizedPath, $rootDir . '/vendor/') === 0; | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + private function makeDecoratedTextWidget(string $topLabel, string $text): array | ||
| 300 | + { | ||
| 301 | + return array( | ||
| 302 | + 'decoratedText' => array( | ||
| 303 | + 'topLabel' => $topLabel, | ||
| 304 | + 'text' => $this->escapeForGoogleChat($text), | ||
| 305 | + 'wrapText' => true, | ||
| 306 | + ), | ||
| 307 | + ); | ||
| 308 | + } | ||
| 309 | + | ||
| 310 | + private function limitText(string $text, int $maxLength): string | ||
| 311 | + { | ||
| 312 | + $maxLength = max(1, $maxLength); | ||
| 313 | + if (mb_strlen($text) <= $maxLength) { | ||
| 314 | + return $text; | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + return mb_substr($text, 0, $maxLength) . '...'; | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + private function escapeForGoogleChat(string $text): string | ||
| 321 | + { | ||
| 322 | + return str_replace( | ||
| 323 | + array('&', '<', '>'), | ||
| 324 | + array('&', '<', '>'), | ||
| 325 | + $text | ||
| 326 | + ); | ||
| 327 | + } | ||
| 328 | + | ||
| 329 | + private function shouldBlockByLoopGuard(Throwable $throwable, array $arrContext = array()): bool | ||
| 330 | + { | ||
| 331 | + if (!(bool) config('daito-exception-notifier.loop_guard_enabled', true)) { | ||
| 332 | + return false; | ||
| 333 | + } | ||
| 334 | + | ||
| 335 | + if (self::$isNotifying) { | ||
| 336 | + return true; | ||
| 337 | + } | ||
| 338 | + | ||
| 339 | + if ((bool) config('daito-exception-notifier.loop_guard_skip_if_notifier_in_trace', true) | ||
| 340 | + && $this->isNotifierRelatedThrowable($throwable) | ||
| 341 | + ) { | ||
| 342 | + return true; | ||
| 343 | + } | ||
| 344 | + | ||
| 345 | + $ttlSeconds = max(1, (int) config('daito-exception-notifier.loop_guard_ttl_seconds', 30)); | ||
| 346 | + $fingerprint = $this->buildExceptionFingerprint($throwable, $arrContext); | ||
| 347 | + if ($this->isProcessCooldownActive($fingerprint)) { | ||
| 348 | + return true; | ||
| 349 | + } | ||
| 350 | + | ||
| 351 | + $this->setProcessCooldown($fingerprint, $ttlSeconds); | ||
| 352 | + | ||
| 353 | + if (!(bool) config('daito-exception-notifier.loop_guard_use_cache', true)) { | ||
| 354 | + return false; | ||
| 355 | + } | ||
| 356 | + | ||
| 357 | + if (!function_exists('cache')) { | ||
| 358 | + return false; | ||
| 359 | + } | ||
| 360 | + | ||
| 361 | + $cacheKeyPrefix = (string) config('daito-exception-notifier.loop_guard_cache_prefix', 'daito-exception-notifier:loop'); | ||
| 362 | + $cacheKey = $cacheKeyPrefix . ':' . $fingerprint; | ||
| 363 | + | ||
| 364 | + try { | ||
| 365 | + return cache()->add($cacheKey, 1, $ttlSeconds) === false; | ||
| 366 | + } catch (Throwable $throwable) { | ||
| 367 | + return false; | ||
| 368 | + } | ||
| 369 | + } | ||
| 370 | + | ||
| 371 | + private function isNotifierRelatedThrowable(Throwable $throwable): bool | ||
| 372 | + { | ||
| 373 | + $traceText = $throwable->getTraceAsString(); | ||
| 374 | + if (strpos($traceText, 'Daito\\Lib\\DaitoExceptionNotifier\\') !== false) { | ||
| 375 | + return true; | ||
| 376 | + } | ||
| 377 | + | ||
| 378 | + return strpos($traceText, 'Daito\\Lib\\DaitoGoogleChat\\') !== false; | ||
| 379 | + } | ||
| 380 | + | ||
| 381 | + private function buildExceptionFingerprint(Throwable $throwable, array $arrContext = array()): string | ||
| 382 | + { | ||
| 383 | + $action = isset($arrContext['action']) ? (string) $arrContext['action'] : $this->resolveCurrentAction(); | ||
| 384 | + $text = implode('|', array( | ||
| 385 | + get_class($throwable), | ||
| 386 | + (string) $throwable->getFile(), | ||
| 387 | + (string) $throwable->getLine(), | ||
| 388 | + (string) $throwable->getMessage(), | ||
| 389 | + $action, | ||
| 390 | + )); | ||
| 391 | + | ||
| 392 | + return sha1($text); | ||
| 393 | + } | ||
| 394 | + | ||
| 395 | + private function isProcessCooldownActive(string $fingerprint): bool | ||
| 396 | + { | ||
| 397 | + $expiresAt = self::$arrFingerprintCooldowns[$fingerprint] ?? 0.0; | ||
| 398 | + return $expiresAt > microtime(true); | ||
| 399 | + } | ||
| 400 | + | ||
| 401 | + private function setProcessCooldown(string $fingerprint, int $ttlSeconds): void | ||
| 402 | + { | ||
| 403 | + self::$arrFingerprintCooldowns[$fingerprint] = microtime(true) + max(1, $ttlSeconds); | ||
| 404 | + } | ||
| 405 | +} |
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace Daito\Lib\DaitoExceptionNotifier\Providers; | ||
| 4 | + | ||
| 5 | +use Daito\Lib\DaitoExceptionNotifier\DaitoExceptionNotifierManager; | ||
| 6 | +use Daito\Lib\DaitoGoogleChat\DaitoGoogleChatManager; | ||
| 7 | +use Illuminate\Support\ServiceProvider; | ||
| 8 | + | ||
| 9 | +class DaitoExceptionNotifierProvider extends ServiceProvider | ||
| 10 | +{ | ||
| 11 | + public function register(): void | ||
| 12 | + { | ||
| 13 | + $this->mergeConfigFrom( | ||
| 14 | + __DIR__ . '/../config/daito-exception-notifier.php', | ||
| 15 | + 'daito-exception-notifier' | ||
| 16 | + ); | ||
| 17 | + | ||
| 18 | + $this->app->singleton(DaitoExceptionNotifierManager::class, function ($app) { | ||
| 19 | + return new DaitoExceptionNotifierManager( | ||
| 20 | + $app->make(DaitoGoogleChatManager::class) | ||
| 21 | + ); | ||
| 22 | + }); | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + public function boot(): void | ||
| 26 | + { | ||
| 27 | + $this->publishes( | ||
| 28 | + array( | ||
| 29 | + __DIR__ . '/../config/daito-exception-notifier.php' => config_path('daito-exception-notifier.php'), | ||
| 30 | + ), | ||
| 31 | + 'daito-exception-notifier-config' | ||
| 32 | + ); | ||
| 33 | + } | ||
| 34 | +} |
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +return array( | ||
| 4 | + 'enabled' => env('DAITO_EXCEPTION_NOTIFIER_ENABLED', true), | ||
| 5 | + 'send_mode' => env('DAITO_EXCEPTION_NOTIFIER_SEND_MODE', 'queue'), // queue|sync | ||
| 6 | + 'card_title' => env('DAITO_EXCEPTION_NOTIFIER_CARD_TITLE', 'Exception Alert'), | ||
| 7 | + 'loop_guard_enabled' => env('DAITO_EXCEPTION_NOTIFIER_LOOP_GUARD_ENABLED', true), | ||
| 8 | + 'loop_guard_ttl_seconds' => env('DAITO_EXCEPTION_NOTIFIER_LOOP_GUARD_TTL_SECONDS', 30), | ||
| 9 | + 'loop_guard_use_cache' => env('DAITO_EXCEPTION_NOTIFIER_LOOP_GUARD_USE_CACHE', true), | ||
| 10 | + 'loop_guard_cache_prefix' => env('DAITO_EXCEPTION_NOTIFIER_LOOP_GUARD_CACHE_PREFIX', 'daito-exception-notifier:loop'), | ||
| 11 | + 'loop_guard_skip_if_notifier_in_trace' => env('DAITO_EXCEPTION_NOTIFIER_LOOP_GUARD_SKIP_NOTIFIER_TRACE', true), | ||
| 12 | + 'message_max_length' => env('DAITO_EXCEPTION_NOTIFIER_MESSAGE_MAX_LENGTH', 1000), | ||
| 13 | + 'trace_mode' => env('DAITO_EXCEPTION_NOTIFIER_TRACE_MODE', 'smart'), // smart|app_only|no_vendor|class_prefix_only | ||
| 14 | + 'trace_class_prefixes' => array('App\\'), | ||
| 15 | + 'trace_include_first_app_frame' => env('DAITO_EXCEPTION_NOTIFIER_TRACE_INCLUDE_FIRST_APP_FRAME', true), | ||
| 16 | + 'trace_max_lines' => env('DAITO_EXCEPTION_NOTIFIER_TRACE_MAX_LINES', 8), | ||
| 17 | + 'trace_max_length' => env('DAITO_EXCEPTION_NOTIFIER_TRACE_MAX_LENGTH', 2500), | ||
| 18 | + 'trace_skip_vendor' => env('DAITO_EXCEPTION_NOTIFIER_TRACE_SKIP_VENDOR', true), | ||
| 19 | +); |
-
Please register or sign in to post a comment