/usr/bin/php /path/to/move_detect_app/tools/dispatch_alerts.php >> /path/alerts.log 2>&1 * * 정확한 크론 표기는 server/docs/server-deploy.md §11 을 보십시오. * * 하는 일 3가지: * 1. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다. * 2. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널). * 3. 실패한 전달을 재시도한다. * * 이 파일이 없으면 이 제품은 "사고를 기록하는 시스템"이지 "사고에 대응하는 시스템"이 아니다. * 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞는 상황이 정확히 이 구멍이었다. * * --dry-run 은 어떤 통보가 나갔을지만 출력하고 아무것도 보내지 않으며 원장에도 쓰지 않는다. * 채널을 처음 설정할 때 반드시 먼저 돌려볼 것 — 잘못된 수신자 목록으로 첫 발송을 하면 * 그 사이트에서는 두 번 다시 이 기능을 켜지 않는다. */ declare(strict_types=1); require __DIR__ . '/bootstrap.php'; use App\Alert\{AlertDispatcher, Channels, EscalationWatch}; use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository}; [$cfg, $pdo] = tool_boot(); $args = tool_args($argv); $now = time(); $dryRun = isset($args['dry-run']); // 창은 재시도 여유를 포함해야 한다: 크론이 몇 번 건너뛰어도 그 사이 이벤트가 통보 없이 // 흘러가면 안 된다. 기본 1시간. $window = max(60, min(86400, (int)($args['window'] ?? 3600))); $escalateAfter = max(30, min(86400, (int)($args['escalate-after'] ?? $cfg->escalateAfterSeconds()))); $limit = max(1, min(2000, (int)($args['limit'] ?? 500))); $maxAttempts = max(1, min(20, (int)($args['max-attempts'] ?? 5))); $events = new PdoEventRepository($pdo); $alerts = new PdoAlertRepository($pdo); $suppressions = new PdoSuppressionRepository($pdo); $baseUrl = $cfg->publicBaseUrl(); if ($baseUrl === '') { fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n"); exit(2); } $channels = new Channels($baseUrl, 8, $cfg->timezone()); // --- 1. 에스컬레이션 발행 ------------------------------------------------------------- $watch = new EscalationWatch($events); $escalated = $dryRun ? [] : $watch->run($now, $escalateAfter, $window, $limit); if ($escalated !== []) { fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n"); } // --- 2. 통보 -------------------------------------------------------------------------- $dispatcher = new AlertDispatcher($events, $alerts, $suppressions); // local 채널은 디스패처가 계획 단계에서 제외한다 (엣지가 자체 GPIO 로 즉시 울린다 — // App\Alert\AlertDispatcher::planFor 주석 참조). 여기 도달하는 것은 email/webhook 뿐이다. $send = function (array $event, array $channel) use ($channels, $dryRun): void { if ($dryRun) { fwrite(STDOUT, sprintf(" [dry-run] event=%d -> %s(%s) : %s\n", $event['event_id'], $channel['kind'], $channel['name'], $channels->subject($event))); return; } $channels->send($event, $channel); }; $stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0]; try { if ($dryRun) { // run() claims a ledger row per delivery, which is exactly what must NOT happen during a // rehearsal: a claimed row is never retried, so a dry run would silently consume the // first real notification for every event in the window. Plan only. foreach ($events->forNotification((float)($now - $window), $limit) as $ev) { $stats['considered']++; foreach ($dispatcher->planFor($ev, $alerts->channels($ev['site'] ?? null), $now) as $d) { if ($d['suppressed']) { $stats['suppressed']++; continue; } $send($ev, $d['channel']); $stats['sent']++; } } } else { $stats = $dispatcher->run($now, $window, $limit, $send); } } catch (Throwable $e) { fwrite(STDERR, date('c') . ' dispatcher error: ' . $e->getMessage() . "\n"); exit(1); } // --- 3. 실패 재시도 ------------------------------------------------------------------- $retried = 0; if (!$dryRun) { foreach ($alerts->retryable($maxAttempts, 100) as $row) { $event = $events->find((int)$row['event_id']); if ($event === null) continue; // 재시도 시점에 이미 확인된 사건이면 더 보내지 않는다 — 사람이 이미 붙었다. if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) continue; $channel = null; foreach ($alerts->allChannels(null) as $c) { if ((int)$c['channel_id'] === (int)$row['channel_id']) { $channel = $c; break; } } if ($channel === null) continue; try { $channels->send($event, $channel); $alerts->markSent((int)$row['event_id'], (int)$row['channel_id'], $now); $retried++; } catch (Throwable $e) { $alerts->markFailed((int)$row['event_id'], (int)$row['channel_id'], $e->getMessage(), $now); } } } fwrite(STDOUT, sprintf( "%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d%s\n", date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'], $stats['failed'], $stats['skipped'], $retried, count($escalated), $dryRun ? ' (dry-run)' : '' )); exit(0);