From 7ab8a0fd7c747c446e786471c354275baba469be Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 18:32:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20P0=20=EC=98=A8=EC=BD=9C=EC=9D=84=201?= =?UTF-8?q?=EC=B0=A8=20=EB=8B=B4=EB=8B=B9=20=E2=86=92=2010=EB=B6=84=20?= =?UTF-8?q?=EB=AF=B8=EC=9D=91=EB=8B=B5=20=EC=8B=9C=20=ED=8C=80=EC=9B=90=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EB=A1=9C=20PagerDuty=20=EC=97=90=20=EC=9E=87?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alerting/pagerduty.py setup 이 에스컬레이션 정책(1차 → 10분 → 팀원, 2회 반복)·서비스(항상 high, ACK 15분 재호출, 자동 종료 끔)·공식 CloudWatch 연동을 만들고 서울·버지니아 SNS ruleup-prod-pagerduty 를 HTTPS 로 구독시킨 뒤 원장에 ID 만 적는다. p0_p1.py 를 다시 돌리면 P0 경보에 토픽이 붙는다. test-trigger·test-resolve 로 실수신·에스컬레이션을 시험한다. API 키·연동 키·이메일은 저장소에 두지 않는다. Co-Authored-By: Claude Opus 5.5 --- infra/monitoring/README.md | 2 +- infra/monitoring/alarm-catalog.yaml | 2 +- infra/monitoring/alerting/README.md | 13 +- infra/monitoring/alerting/pagerduty.py | 187 +++++++++++++++++++ infra/monitoring/alerting/routing.py | 2 +- infra/monitoring/alerting/tests_pagerduty.py | 39 ++++ infra/monitoring/env/prod.json | 42 ++++- infra/monitoring/env/stg.json | 2 +- 8 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 infra/monitoring/alerting/pagerduty.py create mode 100644 infra/monitoring/alerting/tests_pagerduty.py diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md index 601e0954..49912535 100644 --- a/infra/monitoring/README.md +++ b/infra/monitoring/README.md @@ -59,7 +59,7 @@ | 항목 | 막는 것 | 그 전까지 | |---|---|---| -| PagerDuty(G01·G03), OBS-03/05/06 | 계약·계정 없음 | P0 = Slack 긴급 채널 멘션. 원장 `sns.pagerduty` 에 토픽 이름을 넣으면 P0 경보에 붙는다 | +| PagerDuty 개통(G01·G03), OBS-03/05/06 | 계약·계정·API 키 없음 | 설정은 `alerting/pagerduty.py` 로 준비됨(1차 → 10분 미ACK → 팀원). 개통 전 P0 = Slack 긴급 채널 멘션 | | OBS-04 외부 watchdog | Healthchecks.io 없음 | CloudWatch 하트비트 5분 누락 P0 로 대신(같은 리전 장애는 못 잡음). 원장 `healthchecks.obs04_ping_url` 이 생기면 계산기가 외부 ping 도 한다 | | APP-01~04·ERR-01 (G06) | Crashlytics·Sentry 연결 미검증 | SLO·ERROR 로그 패널 | | AUTH-01 | 지점별 격리 시험 계정·Secrets | C01 SLO(실사용 로그인) | diff --git a/infra/monitoring/alarm-catalog.yaml b/infra/monitoring/alarm-catalog.yaml index bc4e939f..3d01e407 100644 --- a/infra/monitoring/alarm-catalog.yaml +++ b/infra/monitoring/alarm-catalog.yaml @@ -14,7 +14,7 @@ schema_version: 2 status: proposed resource_registry: infra/monitoring/env/.json -pager: PagerDuty # 미계약 — 원장 sns.pagerduty 가 null 인 동안 P0 는 Slack 긴급 채널 멘션만 +pager: PagerDuty # alerting/pagerduty.py — 1차 담당 → 10분 미ACK → 팀원(2회 반복). 미계약이라 원장 sns.pagerduty 가 null 인 동안 P0 는 Slack 긴급 채널 멘션만 watchdog: Healthchecks.io # 미계약 — 원장 healthchecks.* 가 null slo: window_days: 30 diff --git a/infra/monitoring/alerting/README.md b/infra/monitoring/alerting/README.md index 4114b3c0..57a7c152 100644 --- a/infra/monitoring/alerting/README.md +++ b/infra/monitoring/alerting/README.md @@ -62,5 +62,14 @@ EventBridge 규칙이 서울·버지니아 모두 `ruleup-prod-P0-` 접두어로 누락 정책: 커스텀 P0·EDGE leaf = `ignore`(입력이 끊겨도 정상으로 돌아가지 않는다 — 재무장은 정상 Flag=0 또는 `../lambdas/rearm.py`), 하트비트·NATIVE-01 = `breaching`, 나머지 = `notBreaching`. -**온콜**: Incident Manager(`ssm-contacts`·`ssm-incidents`)는 조직 SCP 가 막는다. 노션은 PagerDuty 를 채택했지만 아직 계약 전이라, -지금 P0 의 호출 수단은 긴급 채널 멘션(`p0_mention.py`·`routing.py`)뿐이다. 원장 `sns.pagerduty` 가 생기면 P0 경보 AlarmActions 에 붙는다. +**온콜**(`pagerduty.py`, prod 만): **1차 담당(이성은)에게 먼저 → 10분 안에 ACK 가 없으면 팀원 → 그래도 없으면 처음부터 다시(2회)**. +PagerDuty 서비스 「RuleUp prod P0」 — 긴급도 항상 high, ACK 후 15분 안에 해결 안 되면 다시 울림, 시간 기반 자동 종료 끔. +CloudWatch → 같은 리전 SNS `ruleup-prod-pagerduty`(서울·버지니아) → PagerDuty 공식 CloudWatch 연동. 경보가 OK 로 돌아오면 사건이 닫힌다. +Incident Manager(`ssm-contacts`·`ssm-incidents`)는 조직 SCP 가 막아 쓰지 않는다. 긴급 채널 멘션(`p0_mention.py`)은 보조로 그대로 둔다. + +개통 순서(PagerDuty 계정·API 키가 생긴 뒤): +1. 두 사람 모두 PagerDuty 앱 설치 + 프로필 알림 규칙에 **High-urgency: 즉시 푸시 + 1분 뒤 전화**를 넣는다(서비스가 아니라 사람마다 설정이다). +2. `PAGERDUTY_API_TOKEN=… python3 infra/monitoring/alerting/pagerduty.py setup --primary <내 PD 이메일> --secondary <팀원 PD 이메일>` — 원장 `env/prod.json` 이 채워진다 → 커밋. +3. `python3 infra/monitoring/alerting/p0_p1.py prod` — P0 경보 AlarmActions·OKActions 에 PagerDuty 토픽이 붙는다. +4. 실수신 시험(G01): `pagerduty.py test-trigger` → 내 폰이 울리면 **ACK 하지 않고** 10분 기다려 팀원 폰이 울리는지 → `pagerduty.py test-resolve`. + 노션 「대시보드·알림 요약표」 P0 온콜 연결 체크표 12종은 경보별로 `aws cloudwatch set-alarm-state` 로 한 번씩 확인한다(다음 평가에서 OK 로 돌아온다). diff --git a/infra/monitoring/alerting/pagerduty.py b/infra/monitoring/alerting/pagerduty.py new file mode 100644 index 00000000..d5cddf24 --- /dev/null +++ b/infra/monitoring/alerting/pagerduty.py @@ -0,0 +1,187 @@ +"""P0 온콜 — PagerDuty 에스컬레이션과 CloudWatch 연동. 재실행 안전. prod 만(stg 의 P0 는 시험 채널에만 간다). + +호출 순서(2026-10-15 결정): 1차 담당(이성은)에게 바로 → 10분 안에 ACK 가 없으면 팀원 → 그래도 없으면 처음부터 다시(repeat_loops). +노션 카탈로그 「페이징 도구 결정」의 5분 지원자 통보·10분 재호출을 이 순서로 바꿨다. + + export PAGERDUTY_API_TOKEN=... # PagerDuty REST API 키(User 또는 General Access, 쓰기). 저장소·원장에 넣지 않는다 + python3 infra/monitoring/alerting/pagerduty.py setup --primary <1차 PD 계정 이메일> --secondary <팀원 PD 계정 이메일> + python3 infra/monitoring/alerting/p0_p1.py prod # 원장 sns.pagerduty 가 채워졌으니 P0 경보에 토픽이 붙는다 + python3 infra/monitoring/alerting/pagerduty.py test-trigger # 시험 사고 — ACK 하지 말고 10분 뒤 팀원 폰이 울리는지 본다 + python3 infra/monitoring/alerting/pagerduty.py test-resolve + +setup 이 하는 일 + 1. 이메일로 PagerDuty 사용자를 찾는다(두 사람 모두 PagerDuty 계정·앱 설치·전화 알림 규칙이 먼저 있어야 한다). + 2. 에스컬레이션 정책 「RuleUp prod P0」 — 규칙 1: 1차 담당, 10분 / 규칙 2: 팀원, 10분 / repeat_loops 회 반복. + 3. 서비스 「RuleUp prod P0」 — 긴급도 항상 high, ACK 후 15분 안에 해결이 없으면 다시 울림, 시간 기반 자동 종료 끔(노션 §2). + 4. 서비스에 공식 Amazon CloudWatch 연동을 붙이고, 서울·버지니아에 SNS 토픽 ruleup-prod-pagerduty 를 만들어 HTTPS 로 구독시킨다 + (CloudWatch → 같은 리전 SNS → PagerDuty 직결 — 자체 Lambda·릴레이를 거치지 않는다). 구독 확인은 PagerDuty 가 자동으로 한다. + 5. 원장 env/prod.json 에 사용자·정책·서비스 ID 와 sns.pagerduty 를 적는다(이메일·연동 키는 적지 않는다) → 커밋한다. +경보가 OK 로 돌아오면 같은 토픽(OKActions)으로 PagerDuty 사건이 닫힌다. +""" +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone + +HERE = os.path.dirname(os.path.abspath(__file__)) +LEDGER = os.path.join(HERE, '..', 'env', 'prod.json') +API = 'https://api.pagerduty.com' +NAME = 'RuleUp prod P0' +TOPIC = 'ruleup-prod-pagerduty' +TEST_ALARM = 'ruleup-prod-P0-ONCALL-TEST' + + +def escalation_policy(primary_id, secondary_id, delay_minutes, loops): + """1차 담당 → delay 분 미ACK → 팀원 → delay 분 미ACK → 처음부터(loops 회).""" + rule = lambda uid: {'escalation_delay_in_minutes': delay_minutes, 'targets': [{'id': uid, 'type': 'user_reference'}]} + return {'type': 'escalation_policy', 'name': NAME, 'num_loops': loops, + 'description': f'RuleUp prod P0: primary first, teammate after {delay_minutes} min without ACK', + 'escalation_rules': [rule(primary_id), rule(secondary_id)]} + + +def service(policy_id, ack_timeout_minutes): + return {'type': 'service', 'name': NAME, 'description': 'CloudWatch P0 alarms (ruleup-prod-P0-*) via SNS', + 'escalation_policy': {'id': policy_id, 'type': 'escalation_policy_reference'}, + 'incident_urgency_rule': {'type': 'constant', 'urgency': 'high'}, + 'acknowledgement_timeout': ack_timeout_minutes * 60, + 'auto_resolve_timeout': None, + 'alert_creation': 'create_alerts_and_incidents'} + + +def cloudwatch_message(account, region, state, now): + """CloudWatch 경보가 SNS 로 보내는 본문과 같은 모양 — PagerDuty CloudWatch 연동은 AlarmArn 으로 같은 사건을 묶는다.""" + return {'AlarmName': TEST_ALARM, 'AlarmDescription': '온콜 시험 — 1차 담당이 ACK 하지 않으면 10분 뒤 팀원에게 간다', + 'AWSAccountId': account, 'NewStateValue': state, 'OldStateValue': 'OK' if state == 'ALARM' else 'ALARM', + 'NewStateReason': 'pagerduty.py on-call escalation test', 'StateChangeTime': now.strftime('%Y-%m-%dT%H:%M:%S.000+0000'), + 'Region': region, 'AlarmArn': f'arn:aws:cloudwatch:{region}:{account}:alarm:{TEST_ALARM}', + 'Trigger': {'MetricName': 'OnCallTest', 'Namespace': 'RuleUp/Observe', 'Statistic': 'MAXIMUM', 'Period': 60, + 'EvaluationPeriods': 1, 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', 'Threshold': 1.0}} + + +class PagerDuty: + def __init__(self, token): + self.token = token + + def call(self, method, path, body=None, query=None): + url = API + path + ('?' + urllib.parse.urlencode(query, doseq=True) if query else '') + req = urllib.request.Request(url, method=method, data=None if body is None else json.dumps(body).encode(), headers={ + 'Authorization': f'Token token={self.token}', 'Accept': 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read() or b'{}') + except urllib.error.HTTPError as e: + sys.exit(f'PagerDuty {method} {path} → {e.code}: {e.read().decode(errors="replace")[:300]}') + + def user_id(self, email): + users = [u for u in self.call('GET', '/users', query={'query': email})['users'] if u['email'].lower() == email.lower()] + if not users: + sys.exit(f'PagerDuty 사용자가 없다 — 먼저 초대·가입시킨다: {email}') + return users[0]['id'] + + def find(self, kind, name): + key = kind.rstrip('s') if kind != 'escalation_policies' else 'escalation_policy' + hits = [x for x in self.call('GET', f'/{kind}', query={'query': name})[kind] if x['name'] == name] + return hits[0]['id'] if hits else None, key + + def upsert(self, kind, body): + found, key = self.find(kind, body['name']) + if found: + return self.call('PUT', f'/{kind}/{found}', {key: body})[key]['id'] + return self.call('POST', f'/{kind}', {key: body})[key]['id'] + + def cloudwatch_integration_key(self, service_id): + svc = self.call('GET', f'/services/{service_id}', query={'include[]': 'integrations'})['service'] + for i in svc.get('integrations', []): + if i.get('type') == 'aws_cloudwatch_inbound_integration': + return self.call('GET', f'/services/{service_id}/integrations/{i["id"]}')['integration']['integration_key'] + vendors = [v for v in self.call('GET', '/vendors', query={'query': 'Amazon CloudWatch'})['vendors'] if v['name'] == 'Amazon CloudWatch'] + if not vendors: + sys.exit('PagerDuty 에서 Amazon CloudWatch 연동(vendor)을 찾지 못했다') + body = {'type': 'aws_cloudwatch_inbound_integration', 'name': 'CloudWatch via SNS', + 'vendor': {'id': vendors[0]['id'], 'type': 'vendor_reference'}} + return self.call('POST', f'/services/{service_id}/integrations', {'integration': body})['integration']['integration_key'] + + +def load(): + with open(LEDGER, encoding='utf-8') as f: + return json.load(f) + + +def save(reg): + with open(LEDGER, 'w', encoding='utf-8') as f: + json.dump(reg, f, ensure_ascii=False, indent=2) + f.write('\n') + + +def regions(reg): + return [reg['api_region'], reg['monitor_region']] + + +def subscribe(reg, endpoint): + import boto3 + sys.path.insert(0, HERE) + import routing # 토픽 정책(계정 소유자 + CloudWatch 발행)을 같은 모양으로 + for region in regions(reg): + sns = boto3.client('sns', region_name=region) + arn = routing.topic(sns, TOPIC, reg['account_id'], ['cloudwatch.amazonaws.com']) + subs = [s for p in sns.get_paginator('list_subscriptions_by_topic').paginate(TopicArn=arn) for s in p['Subscriptions']] + for s in subs: # 연동을 다시 만들어 키가 바뀌었으면 옛 구독을 지운다 + if s['Protocol'] == 'https' and s['Endpoint'] != endpoint and s['SubscriptionArn'].startswith('arn:'): + sns.unsubscribe(SubscriptionArn=s['SubscriptionArn']) + print(' unsubscribed old endpoint', region) + if not any(s['Endpoint'] == endpoint for s in subs): + sns.subscribe(TopicArn=arn, Protocol='https', Endpoint=endpoint) + print(' subscribed', region, TOPIC) + + +def setup(args): + token = os.environ.get('PAGERDUTY_API_TOKEN') or sys.exit('PAGERDUTY_API_TOKEN 이 없다') + reg = load() + pd_cfg = reg['pagerduty'] + pd = PagerDuty(token) + primary, secondary = pd.user_id(args.primary), pd.user_id(args.secondary) + policy_id = pd.upsert('escalation_policies', escalation_policy(primary, secondary, pd_cfg['escalation_delay_minutes'], pd_cfg['repeat_loops'])) + print('escalation policy', policy_id, f"(1차 → {pd_cfg['escalation_delay_minutes']}분 → 팀원, {pd_cfg['repeat_loops']}회 반복)") + service_id = pd.upsert('services', service(policy_id, pd_cfg['ack_timeout_minutes'])) + print('service', service_id) + key = pd.cloudwatch_integration_key(service_id) + subscribe(reg, f'https://events.pagerduty.com/integration/{key}/enqueue') + pd_cfg.update(primary_user_id=primary, secondary_user_id=secondary, escalation_policy_id=policy_id, service_id=service_id) + reg['sns']['pagerduty'] = TOPIC + save(reg) + print('원장 env/prod.json 갱신 — 커밋하고 p0_p1.py prod 를 다시 돌려 P0 경보에 토픽을 붙인다') + + +def publish_test(state): + import boto3 + reg = load() + if not reg['sns'].get('pagerduty'): + sys.exit('원장 sns.pagerduty 가 비어 있다 — setup 먼저') + region = reg['api_region'] + arn = f"arn:aws:sns:{region}:{reg['account_id']}:{reg['sns']['pagerduty']}" + msg = cloudwatch_message(reg['account_id'], region, state, datetime.now(timezone.utc)) + boto3.client('sns', region_name=region).publish(TopicArn=arn, Subject=f'{state}: "{TEST_ALARM}"', Message=json.dumps(msg, ensure_ascii=False)) + print(state, TEST_ALARM, '→', arn) + if state == 'ALARM': + print('ACK 하지 말 것 — 10분 뒤 팀원에게 가는지 확인하고 test-resolve 로 닫는다', time.strftime('%H:%M:%S')) + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest='cmd', required=True) + s = sub.add_parser('setup') + s.add_argument('--primary', required=True, help='1차 담당 PagerDuty 계정 이메일') + s.add_argument('--secondary', required=True, help='10분 미응답 시 받을 팀원 PagerDuty 계정 이메일') + sub.add_parser('test-trigger') + sub.add_parser('test-resolve') + a = ap.parse_args() + if a.cmd == 'setup': + setup(a) + else: + publish_test('ALARM' if a.cmd == 'test-trigger' else 'OK') diff --git a/infra/monitoring/alerting/routing.py b/infra/monitoring/alerting/routing.py index 1e20f6f3..1fd52411 100644 --- a/infra/monitoring/alerting/routing.py +++ b/infra/monitoring/alerting/routing.py @@ -6,7 +6,7 @@ ruleup-prod-p0-alerts (us-east-1) → 경보 AlarmActions 용(구독 없음 — 서울과 같은 구조) ruleup-prod-p0-slack (us-east-1) → EventBridge(us-east-1) 가 P0 상태 변경을 멘션 알림으로 바꿔 보낸다 → ruleup-p0-urgent 서울 쪽 구성(slack_channels.py·p0_mention.py)은 그대로 두고 토픽 목록에 us-east-1 것을 더한다. -SNS 는 전달만 한다 — ACK·에스컬레이션은 PagerDuty 개통 후(원장 sns.pagerduty)에 생긴다. +SNS 는 전달만 한다 — ACK·에스컬레이션(1차 → 10분 미ACK → 팀원)은 pagerduty.py 가 만드는 PagerDuty 서비스가 한다. """ import json import os diff --git a/infra/monitoring/alerting/tests_pagerduty.py b/infra/monitoring/alerting/tests_pagerduty.py new file mode 100644 index 00000000..eb9b867f --- /dev/null +++ b/infra/monitoring/alerting/tests_pagerduty.py @@ -0,0 +1,39 @@ +"""pagerduty.py 계약 시험 — python3 infra/monitoring/alerting/tests_pagerduty.py (PagerDuty·AWS 호출 없음)""" +import json, os, sys, unittest +from datetime import datetime, timezone +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import pagerduty as pd + + +class PagerDutyTest(unittest.TestCase): + + def test_primary_first_then_teammate_after_ten_minutes(self): + """나에게 먼저, 10분 안에 ACK 가 없으면 팀원에게 — 규칙 순서가 곧 호출 순서다.""" + reg = pd.load()['pagerduty'] + body = pd.escalation_policy('PRIMARY', 'TEAMMATE', reg['escalation_delay_minutes'], reg['repeat_loops']) + rules = body['escalation_rules'] + self.assertEqual([r['targets'][0]['id'] for r in rules], ['PRIMARY', 'TEAMMATE']) + self.assertEqual(rules[0]['escalation_delay_in_minutes'], 10) + self.assertGreaterEqual(body['num_loops'], 1, '팀원도 안 받으면 처음부터 다시 울린다') + + def test_service_never_auto_resolves_and_always_high(self): + body = pd.service('POLICY', 15) + self.assertIsNone(body['auto_resolve_timeout'], '시간이 지났다고 사건을 닫지 않는다(노션 §2)') + self.assertEqual(body['acknowledgement_timeout'], 900) + self.assertEqual(body['incident_urgency_rule'], {'type': 'constant', 'urgency': 'high'}) + + def test_test_message_looks_like_cloudwatch(self): + m = pd.cloudwatch_message('123', 'ap-northeast-2', 'ALARM', datetime(2026, 10, 15, tzinfo=timezone.utc)) + self.assertTrue({'AlarmName', 'NewStateValue', 'AlarmArn', 'StateChangeTime', 'AWSAccountId', 'Trigger'} <= set(m)) + self.assertTrue(m['AlarmName'].startswith('ruleup-prod-P0-'), '긴급 채널 멘션과 같은 접두어') + json.dumps(m) + + def test_ledger_keeps_no_secrets(self): + text = json.dumps(pd.load(), ensure_ascii=False) + self.assertNotIn('@', text, '원장에 이메일을 넣지 않는다') + self.assertNotIn('integration_key', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/infra/monitoring/env/prod.json b/infra/monitoring/env/prod.json index 8a4b049d..d6c0a8b8 100644 --- a/infra/monitoring/env/prod.json +++ b/infra/monitoring/env/prod.json @@ -4,7 +4,10 @@ "account_id": "961178969292", "api_region": "ap-northeast-2", "monitor_region": "us-east-1", - "probe_origins": {"oregon": "us-west-2", "virginia": "us-east-1"}, + "probe_origins": { + "oregon": "us-west-2", + "virginia": "us-east-1" + }, "public_domain": "prod.ruleup.co.kr", "public_health_path": "/health/public", "public_health_token": "ruleup-ok", @@ -16,9 +19,18 @@ "load_balancer": "app/ruleup-prod-alb/30526d51ec29a005", "target_group": "targetgroup/ruleup-prod-tg/d3db371c903b8892", "rds_instance": "ruleup-prod-mysql-v2", - "redis_nodes": ["ruleup-prod-redis-001", "ruleup-prod-redis-002"], - "queues": ["ruleup-prod-moderation", "ruleup-prod-notifications"], - "dlqs": ["ruleup-prod-moderation-dlq", "ruleup-prod-notifications-dlq"], + "redis_nodes": [ + "ruleup-prod-redis-001", + "ruleup-prod-redis-002" + ], + "queues": [ + "ruleup-prod-moderation", + "ruleup-prod-notifications" + ], + "dlqs": [ + "ruleup-prod-moderation-dlq", + "ruleup-prod-notifications-dlq" + ], "sns": { "alerts": "ruleup-prod-alerts", "p0": "ruleup-prod-p0-alerts", @@ -26,9 +38,25 @@ }, "slack_p0_mention_member": "U0AVBAWH05Q", "route53_health_check_id": "26710503-030a-4b93-8df5-e7339b675632", - "healthchecks": {"obs03_ping_url": null, "obs04_ping_url": null}, - "pagerduty": {"service_id": null, "api_key_secret": null}, - "dashboards": {"ops": "ruleup-prod-ops", "observe": "ruleup-prod-observe", "business": "ruleup-prod-business"}, + "healthchecks": { + "obs03_ping_url": null, + "obs04_ping_url": null + }, + "pagerduty": { + "_note": "pagerduty.py setup 이 채운다. 1차 담당 → escalation_delay_minutes 분 미ACK → 팀원 → repeat_loops 회 반복. API 키·연동 키·이메일은 넣지 않는다", + "service_id": null, + "escalation_policy_id": null, + "primary_user_id": null, + "secondary_user_id": null, + "escalation_delay_minutes": 10, + "repeat_loops": 2, + "ack_timeout_minutes": 15 + }, + "dashboards": { + "ops": "ruleup-prod-ops", + "observe": "ruleup-prod-observe", + "business": "ruleup-prod-business" + }, "runbook_url": "https://app.notion.com/p/3f5ad4145fb6811da317e294cf339757", "catalog_url": "https://app.notion.com/p/3f5ad4145fb681bbabccefd603336193", "llm_daily_budget_usd": null, diff --git a/infra/monitoring/env/stg.json b/infra/monitoring/env/stg.json index e4f96d5e..8038fff8 100644 --- a/infra/monitoring/env/stg.json +++ b/infra/monitoring/env/stg.json @@ -27,7 +27,7 @@ "slack_p0_mention_member": null, "route53_health_check_id": "7f7ffb91-fd17-4afa-9f08-85bdf7d13d32", "healthchecks": {"obs03_ping_url": null, "obs04_ping_url": null}, - "pagerduty": {"service_id": null, "api_key_secret": null}, + "pagerduty": {"_note": "stg 는 온콜 없음 — P0 도 시험 채널(ruleup-stg-alerts)만"}, "dashboards": {"ops": "ruleup-stg-ops", "observe": "ruleup-stg-observe", "business": null}, "runbook_url": "https://app.notion.com/p/3f5ad4145fb6811da317e294cf339757", "catalog_url": "https://app.notion.com/p/3f5ad4145fb681bbabccefd603336193",