Architecture
파이프라인
클라이언트에 커스텀 계측이 없습니다 — Claude Code의 native OTel export를 그대로 받습니다.
collector-config.yaml · clickhouse exporter
저장 계층
-
otel_metrics_sum— 원본 메트릭.ReplicatedMergeTree, 3 replica, hot/cold 정책(로컬 EBS gp3 → 45~90일 후 S3 → 90~180일 후 삭제). Model / TokenType / Decision / SkillName / UserEmail / SessionId 등을 promoted materialized column으로 올려 쿼리마다Attributesmap을 뒤지지 않습니다. -
otel_metrics_sum_hourly— 대시보드가 실제로 읽는 시간별 rollup (AggregatingMergeTree+ materialized view). 원본은 세션이 살아있는 동안 10초마다 전 시리즈를 재-export해서 3일에 9.5M행(+3M행/일)까지 갔고, 매 쿼리 풀스캔이 26M read rows·단독 2~4.5초였습니다. 시간별로 접으면 약 86배 작아집니다. otel_logs— 이벤트(tool_result, user_prompt 등). Tool/MCP 사용량 화면의 소스.otel_metrics_gauge— active_time 등 gauge 계열.
누적 카운터 함정
otel_metrics_sum의 값은 델타가 아니라 세션 단위 누적입니다.
sum(Value)를 그대로 쓰면 100배 이상 과대집계됩니다 — 과거에 실제로 당한
문제이고, 이 저장소에서 가장 지키는 규칙입니다.
기간 [시작, 끝)의 실제 증가량은 (시리즈 × 세션) 단위로 "끝 직전 누적값 −
시작 직전 누적값"을 diff해서 구합니다. Prometheus의 increase()와 같은 모양이고,
서버에서는 incFlat(스냅샷) / incBucketed(시계열) 헬퍼가 담당합니다.
SELECT sum(inc) FROM (
SELECT greatest(maxIf(Value, TimeUnix < {끝}) - maxIf(Value, TimeUnix < {시작}), 0) AS inc
FROM otel_metrics_sum
WHERE MetricName = 'claude_code.cost.usage' AND TimeUnix < {끝}
GROUP BY cityHash64(toString(Attributes)), SessionId
)
같은 이유로 "수집이 끊겼는지"는 값의 변화가 아니라 행 도착 여부로 봐야 합니다. 세션이 살아있으면 같은 누적값이 계속 재보고되므로, 시간당 행 수가 고정된 구간은 정상(신규 활동만 없음)이고 끊김은 버킷에 행이 0인 구간입니다.
그룹 판별 (bedrock / enterprise)
Workshop Studio 시나리오에서는 같은 이미지를 쓰고 참가자가 로그인 방식을 고르기 때문에,
EXPERIMENT_GROUP 같은 정적 플래그를 심을 수 없습니다. 그래서 텔레메트리로
사후 판별합니다.
| 우선순위 | 규칙 | 결과 |
|---|---|---|
| 1 | 세션이 호출한 모델 이름에 anthropic. 또는 : 포함 |
bedrock |
| 2 | Bedrock 신호가 없고 datapoint Attributes에 organization.id 존재 |
enterprise |
| 3 | 둘 다 아님 | unknown (약 11%) |
그레인은 SessionId입니다. 리전 프리픽스는 us.일 수도
global.일 수도 있어 프리픽스가 아니라 anthropic. 포함 여부로
판별합니다. organization.id는 ResourceAttributes가 아니라 datapoint별
Attributes에 실립니다 — 처음 가정이 틀렸던 지점입니다.
모델 이름 정규화
Bedrock의 리전·날짜·버전 접미사를 떼어 같은 모델이 비용/사용량 breakdown에서 한 줄로
모이게 합니다. 서버에는 같은 5단계 정규식이 두 곳(normModel() in
queries.js, normalizeModelId() in pricing.js)에
있고 서로 동기 상태를 유지해야 합니다.
global.anthropic.claude-sonnet-5 → claude-sonnet-5
us.anthropic.claude-opus-4-8-v1:0 → claude-opus-4-8
claude-haiku-4-5-20251001 → claude-haiku-4-5
배포 대상 — 특정 오케스트레이터에 종속되지 않는다
애플리케이션은 컨테이너 하나입니다. 멀티스테이지 빌드가 React SPA를 dist/로
만들고, 같은 이미지의 Node 서버가 API와 그 정적 파일을 함께 서빙합니다. 쿠버네티스 API를
호출하는 코드도, 사이드카 전제도 없습니다 — 상태는 전부 ClickHouse에 있고 프로세스는
무상태입니다.
| 대상 | 필요한 것 | 비고 |
|---|---|---|
| EKS (현재 운영) | Deployment 2 replica, Graviton 노드풀, ClickHouse Operator + Keeper | Terraform 전체가 infra/에 있음 |
| ECS / Fargate | linux/arm64 태스크(포트 8080) + ALB, 태스크 롤에 Bedrock 권한(챗을 쓸 때만) |
이미지·환경변수는 동일. 태스크/서비스/ALB IaC는 새로 작성해야 함 |
| 로컬 / 온프렘 | docker compose -f dashboard/docker-compose.yml up |
ClickHouse까지 함께 뜬다 |
공통 전제는 하나뿐입니다 — HTTP로 닿는 ClickHouse. 자체 호스팅이든
매니지드든 앱은 구분하지 않습니다. 헬스체크는 /healthz(Basic Auth 면제,
ClickHouse ping 포함)를 쓰면 됩니다.
AI 분석 경로
POST /api/chat은 Bedrock ConverseStream에 run_sql 툴 하나를
붙인 루프입니다(최대 4 hop). 모델이 SQL을 만들고, 서버가
sanitizeSql()로 검사한 뒤 읽기 전용 계정으로 ClickHouse에 실행하고, 결과
행(최대 200)을 모델에게 돌려주고, 모델이 한국어 요약과 표를 스트리밍합니다.
-
프롬프트가 스키마를 안다 — 누적 카운터 boundary diff, 세션 단위 그룹
판별 CTE, "수집 끊김은 행 도착 여부로 본다"가 시스템 프롬프트에 들어 있습니다. 이게
없으면 모델이
sum(Value)로 과대집계하거나 존재하지 않는 그룹 컬럼을 찾아다닙니다(실제로 겪은 실패입니다). -
샌드박스는 구조로 막는다 —
SELECT/WITH단일 문장만 허용하고, 주석·인용부호를 거부해 토큰 경계를 단순하게 유지한 뒤, FROM/JOIN의 테이블 참조 위치에서identifier(형태(테이블 함수 전체)와claude_code밖 스키마를 거부합니다.readonly=1이 막지 못하는 SSRF 벡터(url()·file()·IMDS)가 여기서 걸립니다. - PII는 결과 단계에서 가린다 — 스트리밍 텍스트를 사후에 정규식으로 고치는 방식은 SSE 청크 경계에서 깨지므로, 툴 결과가 모델에게 돌아가기 전에 마스킹합니다. ClickHouse 오류가 입력값을 에코하는 경로는 플래그와 무관하게 항상 마스킹합니다.
인프라
- EKS (Graviton/arm64 노드풀) + ClickHouse Kubernetes Operator, ClickHouse Keeper 별도 StatefulSet
- ECR — 대시보드 단일 이미지(
linux/arm64), web은 멀티스테이지 빌드로dist/를 만들어 서버가 정적 서빙 - S3 — ClickHouse cold tier
- Route53 + CloudFront — 대시보드와 ClickHouse ingest 각각의 배포
- Terraform 전체 (
infra/) — 모델·리전 같은 운영 값은 변수 한 곳만 고치면 env와 IAM 정책 ARN이 함께 따라갑니다
비용 절감 메모: 대시보드 쿼리를 원본에서 rollup으로 옮긴 것이 가장 큰 효과였습니다 (읽는 행 수 ~86배 감소). PII 보존은 원본과 rollup 모두 180일 TTL로 맞춰, 원본이 지워진 뒤 rollup에 이메일이 남는 우회 경로를 막았습니다.