본문으로 건너뛰기

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.pyresolve_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 는 서명하지 않는다. 그대로 복제하면:

  1. audit_chain_verifyevent 키가 있으면 감사 레코드로 간주한다.
  2. record_hash/hmac/prev_hash 가 없으면 unsignedbroken 으로 집계한다.
  3. 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_BYPASSreason 3종이 같은 이름으로 들어온다.

모든 신규 카운터는 라벨 조합을 import 시점에 .inc(0) 사전 생성한다. /metrics 에 시계열이 없으면 그 자체가 계측 장애의 증거가 된다.

사전 생성 루프도 _preinit() 헬퍼를 거친다 — metrics.pymain.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_totalgend_* 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 를 버려 별건 수정 필요. 이번 계측은 "실제로 요청을 보낸" 사용자 기준