RESAZIP-PC\resaz

add daitoexceptionnotifier

...@@ -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 }
......
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('&amp;', '&lt;', '&gt;'),
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 +);