move_detect/server/tools/dispatch_alerts.php
유창욱 9c0cfd2fea feat(alert): 아무도 발행하지 않던 device_fault·monitoring_degraded 구현
두 유형은 타입 정의(edge/events.py)·서버 검증(EventValidator)·콘솔 필터·
통보 라벨까지 전 구간에 있었으나 생성하는 코드가 어디에도 없었다. 실제로
값을 넣는 것은 seed_demo.php 뿐이었고, 소개서가 내세운 "17종"은 15종이었다.

앞서 고친 EventValidator 누락과 정확히 반대 방향의 같은 병이다. 그때는
감지기가 있는데 서버가 막았고, 이번엔 서버·UI가 열려 있는데 감지기가 없다.
어느 쪽이든 화면은 멀쩡해 보인다.

엣지가 낼 수 없는 유형이라 서버에 둔다 — 전원·회선이 끊긴 노드는 자기가
죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다.

App\Alert\DeviceHealthWatch:
- dispatch_alerts.php 1분 크론의 1단계. 에스컬레이션·통보보다 앞에 두어
  같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 되게 했다.
- 멱등은 EscalationWatch와 같은 방식(event_uuid UNIQUE 충돌)이되, 장치
  건전성은 연속 상태라 uuid 앵커를 '장애 구간의 시작'으로 잡았다.
  device_fault는 last_heartbeat_at(장애 내내 불변) → 장애당 정확히 1건.
  monitoring_degraded는 창 안의 마지막 정상 하트비트 ts. 창 전체가 저하면
  시작을 볼 수 없으므로 창 버킷으로 폴백해 시간당 1회 재고지한다 —
  의도된 리마인더이고, 매 틱 재발행하는 앵커 드리프트가 아니다.
- 성능 저하는 지속을 요구(기본 5분). 한 번 튄 값에 울리면 운영자가 이
  유형을 무시하게 되고, 그러면 감시가 멈춘 날에도 아무도 보지 않는다.
  엣지 감지기들이 쓰는 규율과 같다.
- 하트비트를 한 번도 보낸 적 없는 장치는 장애가 아니다(미설치 노드).
  새 현장 첫날이 빨간 벽이 되면 그 사이트는 이 유형을 영영 신뢰하지 않는다.
- 죽은 노드에 degraded를 겹쳐 내지 않는다 — 한 고장에 두 알람은 소음이다.
- fps NULL을 0으로 강압하지 않는다(측정 실패 ≠ 정지). PdoDeviceMetric
  Repository가 NULL을 지키는 이유와 같다.
- 억제(정비 모드)는 여기서 보지 않는다. 기록은 남기고 통보 여부는
  AlertDispatcher가 정한다 — 정비 창은 전화를 끄는 것이지 노드가 죽었던
  기록을 지우는 것이 아니다.

camera_id는 NOT NULL인데 장치 단위 이벤트에는 카메라가 없다. '__device__'
센티넬을 쓰고, 콘솔은 "장치 전체"로 표시, 구역 편집기 카메라 목록에서는
제외한다(카메라 목록은 보고된 이벤트에서 파생되므로, 두면 존재하지 않는
카메라에 구역을 그리려는 일이 생긴다).

설정 4종 추가(device_stale_seconds 180 · degraded_sustain_seconds 300 ·
degraded_fps_ratio 0.6 · target_fps 12) — 두 config 템플릿에 한국어 설명과
함께. 낮은 프레임으로 운용하는 현장이 target_fps를 안 맞추면 전 노드가
영구 저하로 보고되므로 그 경고를 템플릿에 적었다.

검증: pytest 308 passed(3 skip: 모델 미동봉·shellcheck 미설치·AF_UNIX,
기존과 동일) · phpunit 240 passed(신규 16). dist 재생성으로 패리티 회복.
2026-08-25 10:36:48 +09:00

148 lines
7.2 KiB
PHP

<?php
/**
* 통보 디스패처 — 크론 진입점.
*
* 크론 등록 (1분 주기). 아래 한 줄을 그대로 쓰되, 앞의 분 필드는 별표+슬래시+1 입니다 —
* 이 주석 안에 그대로 적으면 PHP 블록 주석이 거기서 끝나 파일이 파싱되지 않습니다:
*
* <매분> /usr/bin/php /path/to/move_detect_app/tools/dispatch_alerts.php >> /path/alerts.log 2>&1
*
* 정확한 크론 표기는 server/docs/server-deploy.md §11 을 보십시오.
*
* 하는 일 4가지:
* 1. 하트비트가 끊긴 노드에 `device_fault`, 성능이 지속 저하된 노드에 `monitoring_degraded`
* 이벤트를 발행한다. 엣지는 이걸 스스로 못 한다 — 죽은 노드는 자기가 죽었다고 신고할 수 없다.
* 2. 미확인 채로 임계 시간을 넘긴 위급/높음 이벤트에 `unacked_escalation` 이벤트를 발행한다.
* 3. 최근 창의 이벤트를 채널 규칙에 따라 통보한다 (즉시 채널 + 지연 에스컬레이션 채널).
* 4. 실패한 전달을 재시도한다.
*
* 1번이 2·3번보다 먼저 오는 것은 의도적이다: 같은 실행 안에서 발행된 장치 이벤트가 곧바로
* 통보 대상이 되어야 한다. 뒤에 두면 노드가 죽은 사실이 다음 크론까지 1분 늦게 전달된다.
*
* 이 파일이 없으면 이 제품은 "사고를 기록하는 시스템"이지 "사고에 대응하는 시스템"이 아니다.
* 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞는 상황이 정확히 이 구멍이었다.
*
* --dry-run 은 어떤 통보가 나갔을지만 출력하고 아무것도 보내지 않으며 원장에도 쓰지 않는다.
* 채널을 처음 설정할 때 반드시 먼저 돌려볼 것 — 잘못된 수신자 목록으로 첫 발송을 하면
* 그 사이트에서는 두 번 다시 이 기능을 켜지 않는다.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch};
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
PdoDeviceRepository, PdoDeviceMetricRepository};
[$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. 장치 건전성 발행 --------------------------------------------------------------
// 이 두 유형은 서버만 발행할 수 있다. 노드가 응답하지 않는다는 사실은 응답하지 않는 노드가
// 아니라 그것을 기다리는 쪽에서만 관측된다.
$health = new DeviceHealthWatch(
$events, new PdoDeviceRepository($pdo), new PdoDeviceMetricRepository($pdo));
$healthFiled = $dryRun ? [] : $health->run($now, [
'stale_after_s' => $cfg->deviceStaleSeconds(),
'sustain_s' => $cfg->degradedSustainSeconds(),
'fps_ratio' => $cfg->degradedFpsRatio(),
'target_fps' => $cfg->targetFps(),
]);
foreach ($healthFiled as $f) {
fwrite(STDOUT, sprintf("%s %s: %s (event=%d)
",
date('c'), $f['event_type'], $f['device_id'], $f['event_id']));
}
// --- 2. 에스컬레이션 발행 -------------------------------------------------------------
$watch = new EscalationWatch($events);
$escalated = $dryRun ? [] : $watch->run($now, $escalateAfter, $window, $limit);
if ($escalated !== []) {
fwrite(STDOUT, date('c') . " escalated: " . implode(',', $escalated) . "\n");
}
// --- 3. 통보 --------------------------------------------------------------------------
$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);
}
// --- 4. 실패 재시도 -------------------------------------------------------------------
$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);