ADR-0033: 워크스페이스 축 관측 — 메트릭 라벨 vs 감사 로그 분업
- 상태: 승인
- 날짜: 2026-07-30
- 관련: #2719 (본 결정), #2700 (워크스페이스 격리 Epic, Phase 1 PR-7), #2673 (
_audit_unmask선례), #520 (감사 HMAC 체인) - 상세 설계:
docs/DESIGN_WORKSPACE_OBSERVABILITY.md
배경
Epic #2700 의 결정들(특히 D2 admin break-glass)은 "admin 이 어느 워크스페이스를 얼마나 보는가" 를 알아야 판단할 수 있는데, 그 데이터가 존재하지 않았다.
middleware/workspace_active.py의 admin impersonation 이logger.warning한 줄로만 남았다. fluent-bit 은log gend\.audit+event .두 조건으로 필터하므로 이 로그는 감사 인덱스에 적재되지 않고 팟 stdout 에서 소멸한다.auth/workspace_fence.py의resolve_caller_workspace는 admin 분기에서tenant_slug를 읽기도 전에return None한다 — 메트릭도 로그도 없어 admin 의 전 테넌트 읽기가 계측상 존재하지 않았다.mcp_access_history.workspace_id컬럼은 있는데 recorder 가 한 번도 채우지 않아 항상 NULL 이었다. 좌표(datasets)는 채워지므로 컬럼 하나만 채우면 워크스페이스×좌표 분포가 완성된다.
결정
D-1. 워크스페이스 slug/id 를 Prometheus 라벨로 쓰지 않는다
이유는 개수가 아니라 검증되지 않은 입력이라는 것이다.
D3 이 정한 상한 50 자체는 문제가 아니다 — 리포에 이미 workspace_id(UUID)를 라벨로 쓰는 선례가 있다(LLM_QUOTA_BLOCK_TOTAL). 진짜 이유는 impersonation 경로가 헤더 값을 workspaces 테이블에 대조하지 않는다는 것이다. header_slug 는 검증 없이 user.tenant_slug 에 대입된다. admin 토큰 하나로 X-Workspace-Slug: <난수> 를 반복하면 시계열이 무제한 증식한다.
상한 50 은 정상 사용 시의 값이지 라벨이 취할 수 있는 값의 상한이 아니다.
D-2. 워크스페이스 식별은 감사 로그·DB, 메트릭은 유한 카테고리
이 분업은 리포의 기존 관행이기도 하다 — workspace_active_decisions_total 은 slug 대신 decision 카테고리를 라벨로 쓰고 slug 는 로그로 뺀다.
| 질문 | 답하는 곳 |
|---|---|
| 다중 멤버십 사용자 수 | 감사 로그 workspace_membership_count + cardinality(user_id) |
| admin 이 어느 워크스페이스를 봤는가 | 감사 로그 event:workspace.impersonate + terms(tenant_slug) / caller_is_admin:true |
| 워크스페이스×좌표 (MCP) | mcp_access_history JOIN workspaces + datasets 전개 |
| 우회·게이트 빈도 | Prometheus (유한 카테고리 라벨) |
Prometheus 로는 distinct user 를 셀 수 없다는 점도 이 분업의 근거다.
D-3. 신규 gend.audit 이벤트는 반드시 서명한다
★ 이것이 이 ADR 에서 가장 중요하다.
이슈 요구사항은 "#2673 의 _audit_unmask 와 동형"이었다. 그런데 _audit_unmask 는 서명하지 않는다. 그대로 복제하면:
audit_chain_verify는event키가 있으면 감사 레코드로 간주한다.record_hash/hmac/prev_hash가 없으면unsigned→broken으로 집계한다.- prod CronJob 은 과거
START_DATE기준이라 strict 모드다 —signed>0 + unsigned>0이면 "chain transition 의심"으로 exit 1 + Slack.
발생 빈도가 결정타다. UI 는 localStorage 에 active slug 가 있으면 모든 요청에 X-Workspace-Slug 를 붙인다. 멤버십 밖 워크스페이스를 선택한 admin 은 요청마다 impersonation 분기를 탄다 — "가끔"이 아니라 매일 확정적으로 알람이 뜬다.
따라서 explain_audit.py 의 정본(같은 gend.audit 싱크 + AuditChain.sign)을 따른다. "#2673 동형"은 채널·필드·실패 자세를 뜻하고, 서명은 explain_audit 쪽을 따른다.
_audit_unmask 가 미서명인 것은 선례가 아니라 미상환 부채로 기록한다. 같은 부채가 pii_unmask·git.*·intel_license_block 에도 있으며, prod 알람 상태를 확인한 뒤 일괄 처리해야 한다.
D-4. 계측은 삼키되 침묵하지 않는다
inc() 헬퍼가 요청을 절대 깨지 않되, 첫 실패는 WARNING + gend_metric_emit_failures_total{metric} 으로 드러낸다. 조용히 삼키면 "카운터가 0"과 "계측이 죽음"이 구분되지 않는다 — #2683 이 같은 실패 모드로 승격 게이트가 공허해진 전례가 있다.
실패 dedup 키는 (카운터 이름, 라벨 조합) 이다. 이름만으로 묶으면 같은 카운터의
서로 다른 라벨 실패가 한 덩어리가 되어 운영자가 어느 조합이 깨졌는지 알 수 없다 —
WORKSPACE_FENCE_BYPASS 는 reason 3종이 같은 이름으로 들어온다.
모든 신규 카운터는 라벨 조합을 import 시점에 .inc(0) 사전 생성한다. /metrics 에 시계열이 없으면 그 자체가 계측 장애의 증거가 된다.
사전 생성 루프도 _preinit() 헬퍼를 거친다 — metrics.py 는 main.py 가 import
하므로 라벨 오타 하나가 ASGI 부팅 전체를 중단시킬 수 있기 때문이다.
metric 라벨의 :preinit 접미사사전 생성 실패는 별도 메트릭을 만들지 않고 gend_metric_emit_failures_total{metric="<이름>:preinit"}
로 같은 축에 싣는다. "런타임 emit 실패"와 "부팅 시 사전 생성 실패"를 하나의 알람
룰(> 0)로 잡으면서 라벨로 구분하기 위해서다. 사전 생성 실패는 시계열 부재로
이어져 승격 게이트를 오독하게 만들므로 같은 축에서 보는 편이 낫다.
"metric 라벨 = 카운터 이름" 컨벤션과 미세하게 어긋나는 의도된 예외다.
::: #2701 의 SDK_AUTHZ_FILTERED/BYPASS 에 이 사전 생성이 빠져 있었으므로 함께 메웠다.
결과
- impersonation 이 서명된 감사 레코드로 남아 사후 재구성이 가능해진다.
- admin 의 read-fence 우회가 처음으로 계측된다.
SDK_AUTHZ_FILTERED에 분모(gend_sdk_authz_gate_total)가 생겨 observe→enforce 승격을 비율로 판단할 수 있다.mcp_access_history.workspace_id가 채워져 워크스페이스×좌표 분포가 DB 로 답해진다.
이 PR 은 차단을 하나도 추가하지 않는다 — 카운터 증가·로그 emit·NULL 컬럼 채우기·request.state 스탬프뿐이고 판정 분기·반환값·HTTP 상태는 한 줄도 바뀌지 않는다.
범위 밖
| 갭 | 사유 |
|---|---|
workspace_active_decisions_total → gend_* rename | 시계열 단절. 리포에 참조 0건이 prod Grafana 부재의 증거는 아니다 — 신구 동시 emit 기간을 두는 별도 PR |
group_filter.apply_workspace_scope / trino_workspace_filter 의 admin 무계측 우회 | 같은 성격이지만 별개 경로. resolve_caller_workspace 계측으로 REST 읽기는 이미 덮인다 |
_audit_unmask 등 기존 미서명 이벤트 승격 | prod 알람 현황 확인 후 일괄 처리 |
| Grafana 대시보드·알람 룰 | 값 범위를 1주 관측한 뒤 임계값을 정해야 의미 있다. 임계값 없는 알람은 노이즈 |
| Keycloak realm 직접 조회 멤버십 집계 | list_users 가 그룹 path 를 버려 별건 수정 필요. 이번 계측은 "실제로 요청을 보낸" 사용자 기준 |