콘텐츠로 이동

알림 설계

이 문서는 이슈 #2356의 옵트인 아웃바운드 알림 기능을 설명합니다. 백엔드 상태, 서킷, 예산 이벤트를 푸시로 알리는 기능을 다루며, 설정 범위, 라우터가 이미 만들어 내는 이벤트, 전달 계약을 설명합니다. 구현은 src/services/alerting/에 있고, 이벤트 생산자는 src/health.rs, src/core/circuit_breaker/state.rs, src/control_plane/policy_store.rs에 있습니다.

범위

목표:

  • 백엔드가 라우팅에서 제외될 때, 서킷이 열리거나 닫힐 때, 키의 월 예산이 소진되거나 설정된 비율에 가까워질 때 운영자에게 알립니다.
  • 요청 경로와 전달을 분리합니다. 수신 측이 느리거나 연결할 수 없더라도 추론 지연이 늘어나서는 안 됩니다.
  • 라우터가 이미 계산하는 신호를 재사용합니다. 새로운 프로브나 별도 집계를 추가하지 않습니다.

비목표:

  • Prometheus, OTLP, 프로세스 로그, Admin API를 대체하지 않습니다. 알림은 이들 옆에 붙는 푸시 편의 기능이며, 기준 데이터가 아닙니다.
  • 팀별 채널이나 에스컬레이션 같은 라우팅 규칙은 다루지 않습니다. 활성화된 모든 이벤트가 하나의 웹훅 엔드포인트로 전달됩니다.
  • 프롬프트나 완성 본문을 전송하지 않습니다. 알림 페이로드는 메타데이터만 담습니다.

설정

alerting: 섹션은 선택 사항이며, 기본값은 꺼짐입니다.

alerting:
  enabled: false
  webhook:
    url: "https://hooks.example.com/continuum"
    timeout: 5s
  events:
    backend_unhealthy: true
    backend_recovered: true
    circuit_opened: true
    circuit_closed: true
    budget_exhausted: true
    budget_fraction: 0.9
  cooldown: 5m
  max_queue: 256
  retry:
    max_attempts: 3
    initial_delay: 500ms
    max_delay: 30s
필드 타입 기본값 설명
enabled bool false false이면 섹션은 파싱과 검증만 되고, 태스크를 만들지 않으며 요청도 보내지 않습니다
webhook.url string 없음 https 엔드포인트입니다. 활성화 시 필수입니다
webhook.timeout duration 5s 전달 시도 한 번의 제한 시간입니다
webhook.allow_private_network bool false 루프백이나 사설 네트워크 수신 측을 허용합니다(보안 절 참고)
events.backend_unhealthy bool true 헬스 체커가 정상에서 비정상으로 전환할 때 발생합니다
events.backend_recovered bool true 비정상에서 정상으로 전환할 때 발생합니다
events.circuit_opened bool true 백엔드 서킷이 closed를 벗어날 때 발생합니다
events.circuit_closed bool true 서킷이 closed로 돌아올 때 발생합니다
events.budget_exhausted bool true 허브에서 동기화된 키 예산이 소진될 때 발생합니다. control-plane 빌드가 필요합니다
events.budget_fraction float 0.9 키 예산의 이 비율에 도달하면 경고를 한 번 보냅니다. 0이면 끕니다
cooldown duration 5m 이벤트와 대상 단위로 적용됩니다. 구간 안의 추가 발생은 세기만 하고 보내지 않습니다
max_queue integer 256 요청 경로와 전달 태스크 사이의 유한 큐입니다
retry.max_attempts integer 3 첫 시도를 포함한 이벤트당 시도 횟수입니다
retry.initial_delay duration 500ms 첫 백오프 지연입니다
retry.max_delay duration 30s 백오프 증가의 상한입니다

webhook.headers(후속 필드)의 비밀값은 ${ENV_VAR} 치환을 사용하며, 다른 자격 증명 필드와 같이 Admin API와 설정 내보내기에서 마스킹됩니다.

이벤트 소스

이벤트 소스 발생 조건
backend_unhealthy src/health.rs 연속 프로브 실패가 health_checks.unhealthy_threshold에 도달할 때
backend_recovered src/health.rs 연속 프로브 성공이 health_checks.healthy_threshold에 도달할 때
circuit_opened, circuit_closed src/core/circuit_breaker/ open으로, 또는 다시 closed로 바뀌는 전환. circuit_breaker_transitions_total(src/core/circuit_breaker/metrics.rs)을 증가시키는 지점에서 기록
budget_exhausted src/control_plane/policy_store.rs(허브 스냅숏과 로컬 증분) 동기화된 키의 월 예산이 소진될 때
budget_fraction src/control_plane/policy_store.rs(허브 스냅숏과 로컬 증분) 동기화된 키가 설정된 비율을 처음 넘을 때

control-plane 기능이 없으면 로컬 지출 기준이 없으므로 두 예산 이벤트는 발생하지 않습니다. 설정 타입은 항상 컴파일하고 런타임만 게이팅한다는 프로젝트 규칙에 따라, 시작 로그가 예산 이벤트가 비활성임을 알립니다.

전달 계약

  • 단일 태스크: 백그라운드 태스크 하나가 큐를 소유하고 모든 HTTP POST를 수행합니다. 이벤트 생산자는 블로킹하지 않는 try_send로 넘기고 계속 진행합니다.
  • 유한 큐: max_queue에 도달하면 try_send가 실패하므로 새 이벤트를 버리고, alert_dropped_total을 증가시킵니다. 큐가 가득 차도 요청 처리는 지연되지 않습니다.
  • 재시도: 2xx가 아닌 응답은 retry.max_delay로 제한된 지수 백오프로 재시도합니다. 2xx를 받으면 이벤트를 종료합니다. 재시도를 모두 쓰면 alert_deliveries_total{result="failed"}를 증가시킵니다.
  • 쿨다운: 이벤트와 대상(백엔드 이름 또는 키 id)마다 타임스탬프를 하나 유지합니다. 구간 안의 발생은 세기만 하고, 구간이 끝나면 요약에 억제 횟수를 담아 보냅니다.
  • 리로드: 섹션은 핫 리로드에서 다시 읽습니다. 새 URL은 리로드 뒤에 큐에 들어온 이벤트부터 적용되며, 이미 큐에 있는 이벤트는 생성 당시의 URL을 유지합니다.
  • 종료: 종료 시그널을 받은 뒤 전달 태스크는 하드코딩된 5초 동안 대기 중인 이벤트를 비우며, 서버는 이를 기다리지 않으므로 프로세스가 종료될 때 아직 남아 있는 이벤트는 버려집니다.

페이로드

모든 요청은 POST이며 Content-Type: application/json을 사용합니다.

{
  "event": "circuit_opened",
  "occurred_at": "2026-10-28T10:00:00Z",
  "router_id": "continuum-router-1",
  "subject": { "backend": "openai-primary" },
  "detail": { "previous_state": "closed", "state": "open", "failure_status_code": 503 },
  "suppressed": 0
}
  • event는 위에 열거한 활성 이벤트 이름 중 하나입니다.
  • router_id는 라우터가 허브에 등록된 경우 control-plane 라우터 id이고, 그렇지 않으면 호스트 이름입니다.
  • subject는 백엔드 또는 키 id를 담으며, 키 값은 담지 않습니다.
  • detail은 소스 이벤트가 이미 가진 필드만 담습니다. 예를 들어 프로바이더 상태 코드나 소진된 예산 구간입니다.
  • suppressed는 쿨다운으로 합쳐진 발생 횟수입니다.

보안

  • URL은 https여야 하며, 엄격한 SSRF 검증기(src/infrastructure/common/url_validator.rs)를 통과해야 합니다. 이 검증기는 루프백, 사설, 링크 로컬, 클라우드 메타데이터 주소와 그 주소로 해석되는 호스트 이름을 거부합니다. webhook.allow_private_network: true(새 필드, 기본값 false)를 설정하면 클러스터 내부 수신 측을 위해 주소 검사를 해제합니다. 검증만으로는 이름 해석과 연결 사이의 간격을 막을 수 없으므로, 전달 클라이언트는 검증된 주소를 고정해 연결합니다.
  • 자격 증명이 포함된 URL은 로드 오류입니다. 자격 증명은 환경 변수 치환을 거쳐 webhook.headers에 둡니다.
  • 페이로드에는 프롬프트 본문, 완성 본문, API 키, 허브 토큰이 절대 포함되지 않습니다.
  • 수신 측 오류는 URL과 요청 헤더 없이 기록합니다.

메트릭

메트릭 타입 레이블
alert_deliveries_total counter event, result (delivered, failed)
alert_queue_depth gauge 없음
alert_dropped_total counter 없음
alert_coalesced_total counter event

테스트 계획

  • 페이로드 직렬화, 쿨다운 집계, 큐 축출에 대한 단위 테스트를 둡니다.
  • 로컬 HTTP 서버를 사용하는 테스트에서 500 응답 후 재시도, 이후 2xx 성공, 재시도 소진 시 실패 집계, 큐 적재가 네트워크를 건드리지 않음을 확인합니다.
  • 설정 검증 테스트에서 활성화 상태의 URL 누락, 비 https URL, 자격 증명 포함, 범위를 벗어난 비율이 로드 오류인지 확인합니다.
  • 설정 도우미 드리프트 테스트가 sections_count와 ### alerting 카탈로그 항목을 스키마와 동기화합니다. 이 문서와 config.yaml.example의 alerting: 블록은 섹션이 바뀔 때 함께 갱신합니다.

클라이언트 알림(#2416)과의 관계

속도 제한·쿼터 웹훅 설계(웹훅 알림: 설계 기록, 이슈 #2416)는 키를 소유한 클라이언트 애플리케이션에 이벤트를 보냅니다. 알림 기능은 운영 이벤트를 운영자에게 보냅니다. 수신 측, 구독, 페이로드가 다르므로 두 섹션은 분리하지만, 보장 수준이 같도록 전달 구현(유한 큐, 백오프 재시도, SSRF 검증, 전달 메트릭)은 공유해야 합니다.

적용 순서

구현은 아래 순서로 반영되었습니다.

  1. 설정 타입과 검증을 추가했습니다. enabled가 true가 되기 전까지는 비활성입니다.
  2. 알림 태스크와 헬스·서킷 이벤트를 추가했습니다.
  3. control-plane 게이트와 함께 예산 이벤트를 추가했습니다.
  4. config.yaml.example과 설정 도우미 문서의 섹션을 같은 변경에서 기록했습니다.