Claude Code 텔레메트리를 OpenTelemetry로 직접 계측하기 — Grafana 없이 SQLite로 끝내는 최소 구성 (삽질 기록 포함)
목차
- Claude Code는
CLAUDE_CODE_ENABLE_TELEMETRY=1로 세션 수·토큰량·비용·툴 판단 같은 지표를 OTLP로 정식 익스포트할 수 있다. - 이걸 무거운 백엔드(Grafana 스택 등)를 세우지 않고, OTel Collector → JSON Lines → SQLite라는 최소 구성으로 유실 없이(durable) 안정적으로 받는 방법을 만들었다.
- 삽질 포인트가 3개 있었다. (1) redaction을 작성했는데도 account identity가 제거되지 않은 컨텍스트 계층 착오, (2) Collector 재시작으로 파일이 truncate되어 누락, (3) 설정을 넣어도 "다음 신규 세션"부터만 적용됨.
- 교훈: 계측 파이프라인 검증은 과금되는 실제 세션을 돌리는 대신 합성 OTLP 데이터로 돌리는 편이 빠르고 안전하다.
왜 에이전트의 '운영'을 계측하는가
AI에게 코드를 짜게 하는 운영을 계속하다 보면, 모델 벤치마크보다 "우리 에이전트 운영이 실제로 어떻게 돌아가는지"가 더 궁금해져요. 하루에 몇 세션이 돌았는지, 토큰과 비용은 어떻게 추이하는지, 코드 편집 툴의 제안이 얼마나 수락/거부되는지 — 이런 운영 게이지는 태스크별 Markdown 리포트만 들여다봐서는 보이지 않아요.
다행히 Claude Code는 OpenTelemetry를 일급(first-class)으로 지원해서, 환경 변수 하나로 metrics / logs / traces(beta)를 내보낼 수 있어요. 이번에는 우선 metrics만을 최대한 가벼운 상주 형태로 지속 수집하는 기반을 만들었어요. 이 글은 그 구현 절차와, 중간에 밟은 함정들의 기록이에요.
계측 대상은 자사 AI 워커 운영 리포지터리(여러 AI 에이전트가 같은 머신을 공유하며 도는 구성)예요. 고유명사는 가리지만, 구성 자체는 범용적이라 각자 환경에 맞게 바꿔서 재현할 수 있을 거예요.
먼저 용어부터 맞춰두기
이후 이야기는 다음 4가지만 알면 읽을 수 있어요. 반대로 여기가 애매하면 구현 파트가 "왜 이 설정을 쓰는지"가 안 보인 채 그냥 외우는 주문처럼 되어버리니, 먼저 정리하고 갈게요.
- OpenTelemetry(OTel): 애플리케이션이 내보내는 "메트릭(수치 계측값)" "로그" "트레이스(처리 흐름의 기록)"를, 특정 벤더에 묶이지 않는 공통 포맷으로 모으기 위한 업계 표준. 거칠게 말하면 "계측 데이터의 공통 언어". 이번에 쓰는 건 metrics(수치 계측값)뿐.
- OTLP: OpenTelemetry가 데이터를 주고받을 때의 통신 프로토콜(OpenTelemetry Protocol의 약자). Claude Code는 동작 중인 상태(세션 수나 비용 등)를 이 OTLP 형식으로 네트워크 너머로 보낼 수 있다.
- OTel Collector: OTLP로 전송된 데이터를 받아, 필요하면 내용을 가공·필터해서 원하는 저장소로 흘려보내는 "중계역" 프로그램. 이번에 쓰는
otelcol-contrib는 그 Collector 구현 중 하나. 받은 데이터를 손 안 대고 그대로 통과시키기만 하는 게 아니라, "이 항목은 도중에 지운다" 같은 처리(뒤에 나올transform)를 끼워 넣을 수 있는 게 포인트. - 속성(attribute): 데이터 1건에 따라붙는 "태그"나 "메타데이터". 예를 들어 "어떤 모델을 썼는지" "어떤 세션인지" 같은 정보가 수치 자체와는 별개로 따라붙는다. 이 글의 삽질 포인트 대부분은 이 "속성이 어디에 붙어 있는가"를 둘러싼 이야기.
이 정도만 알면, 이후는 "OTLP로 데이터를 보내는 쪽(Claude Code) → OTLP로 받아 중계하는 Collector → 최종 저장소(SQLite)"라는 3단 구조로 읽으면 돼요.
전체 그림

Claude Code (OTLP exporter)
│ grpc://localhost:4317
▼
OTel Collector (otelcol-contrib)
│ receivers: otlp
│ processors: transform ← account identity 제거
│ exporters: file ← JSON Lines로 추가 기록
▼
data/otel_raw/events.jsonl
│ 5분마다 ingest (systemd timer)
▼
SQLite (whitelist schema)
│
▼
효과 측정·게이지 리포트
"왜 Prometheus + Loki + Grafana로 안 하느냐"고 하면, 상주 프로세스를 4개 늘리기 전에 애초에 지속 수집해서 뭐가 보이는지를 싸게 확인하고 싶었기 때문이에요. 기존 운영이 Markdown 중심이었으니, 우선 SQLite에 쌓아두고 나중에 집계하는 최소 구성부터 시작했어요. 유용성이 보이면 풀스택으로 확장한다는 단계별 계획이에요.
구현
1. Claude Code 쪽에서 텔레메트리 활성화
사용자 설정(~/.claude/settings.json)에 환경 변수를 더해요.
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"
}
}
여기서 판단이 하나 있어요. 문서에는 OTEL_LOGS_EXPORTER도 있지만, 이번엔 넣지 않았어요. 뒤에 나올 Collector가 metrics 파이프라인만 갖고 있어서, logs를 보내도 받는 쪽에 갈 곳이 없어 에러만 나기 때문이에요. 최소 구성에서는 "보내는 시그널"과 "받는 시그널"을 일치시켜 두는 게 깔끔해요.
덧붙이면, 프롬프트 본문·응답 본문·툴 입출력은 OTEL_LOG_* 계열 플래그가 전부 기본 OFF라서 나오지 않아요. 이번에 모으는 건 건수·토큰 수·비용·툴 판단 같은 집계 지표뿐이에요.
2. OTel Collector 설정
otelcol-contrib(이번엔 v0.156.0)를 써요. 설정은 이게 전부예요.
receivers:
otlp:
protocols:
grpc:
endpoint: localhost:4317
http:
endpoint: localhost:4318
processors:
transform:
metric_statements:
- context: datapoint
statements:
- delete_key(attributes, "user.email")
- delete_key(attributes, "user.account_uuid")
- delete_key(attributes, "user.account_id")
- delete_key(attributes, "organization.id")
exporters:
file:
path: data/otel_raw/events.jsonl
append: true
service:
pipelines:
metrics:
receivers: [otlp]
processors: [transform]
exporters: [file]
transform 프로세서로 account identity를 지우는 게 포인트예요. 이건 사전 검증에서 알게 된 중요한 사실에 대응해요 — Claude Code는 redaction 플래그 설정과 관계없이 user.email / user.account_uuid / user.account_id / organization.id를 모든 시그널(metrics 포함)에 표준 속성으로 상시 부여해요. OTEL_LOG_* 플래그는 프롬프트/응답/툴 내용을 게이팅할 뿐, 이 account identity에는 효과가 없어요. 그래서 수집 쪽에서 명시적으로 떨궈줘야 해요.
(이 delete_key의 작성 방식에는 함정이 있었어요. 뒤에서 이야기할게요.)
또한, "identity가 표준 속성으로 상시 부여된다"는 주장은 현행 Claude Code 공식 모니터링 문서와 일치합니다. 공식 문서는 OAuth 인증 세션에서 user.email이 텔레메트리 속성에 포함되며, 우려된다면 백엔드에서 필터링/redaction하라고 안내합니다. 또한 user.account_uuid·organization.id는 표준 속성으로 문서화되어 있고(OTEL_METRICS_INCLUDE_ACCOUNT_UUID로 계정 UUID 부여를 제어), 실제 GitHub 이슈들에서도 이들이 metrics의 리소스(resource) 속성으로 채워진다고 보고됩니다. 즉 삽질 1에서 저자가 관찰한 "리소스 계층에 붙는다"는 정황은 실제 동작과도 부합합니다.
한국 환경 보완: user.email과 계정 식별자(user.account_uuid, user.account_id)는 개인정보보호법(PIPA)상 개인정보에 해당합니다. 텔레메트리를 조직이 수집·보관하면 개인정보 처리로 볼 수 있으므로, 저장 이전 단계에서 이들 속성을 제거하는 위 설계는 개인정보 최소 수집 원칙과 잘 맞습니다. 팀 단위로 대시보드를 공유하거나 로그를 장기 보관하는 경우에는, redaction 정책과 접근 통제(권한 분리), 보관 기간을 사전에 정해두는 편이 안전합니다. 사내 개인정보 처리방침·ISMS-P 통제 항목과의 정합성도 함께 점검하는 걸 권합니다.
3. 파일 출력을 SQLite로 취합
Collector가 뱉은 events.jsonl을, 5분마다 읽어 SQLite(가벼운 파일 기반 데이터베이스)에 upsert하는 작은 Python을 짰어요. 핵심은 스키마를 화이트리스트로 만드는 거예요 — "넣어도 되는 항목만 미리 정해두고, 그 외에는 저장 시점에 무시한다"는 설계예요.
CREATE TABLE IF NOT EXISTS events (
event_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
event_type TEXT NOT NULL,
timestamp TEXT NOT NULL,
value REAL,
decision TEXT,
model TEXT,
issue_number INTEGER
);
필요한 컬럼만 갖고 있으니, 설령 생 JSON 쪽에 불필요한 속성이 남아 있어도 분석 대상 DB에는 구조적으로 들어올 수 없어요. redaction을 이중으로 걸어두는 발상이에요(뒤에 나올 삽질에서 이 이중화가 효과를 발휘해요).
OTLP metric 이름은 우리 쪽 event_type으로 매핑해요.
METRIC_NAME_MAP = {
"claude_code.cost.usage": "cost.usage",
"claude_code.token.usage": "token.usage",
"claude_code.session.count": "session.count",
"claude_code.code_edit_tool.decision": "tool.decision",
"claude_code.active_time.total": "active_time.total",
}
4. systemd로 상주·정기 실행
Collector도 취합 스크립트도, 매번 수동으로 돌리는 게 아니라 OS 기동 시점부터 뒤에서 계속 돌리고 싶어요. Linux에는 그걸 위한 구조(systemd)가 표준으로 들어 있으니 그걸 써요. Collector는 상주 서비스, 취합은 5분마다 도는 timer로 했어요(둘 다 자기 사용자 권한만으로 도는 "user unit"이라는 설정 단위).
# collector.service (발췌)
[Service]
Type=simple
WorkingDirectory=%h/<repo>
ExecStart=%h/.local/bin/otelcol-contrib --config %h/<repo>/tools/otel_collector_config.yaml
Restart=on-failure
# ingest.timer (발췌)
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
systemctl --user daemon-reload
systemctl --user enable --now agent-telemetry-collector.service agent-telemetry-ingest.timer
이걸로 Everything is ready. Begin running and processing data.가 뜨면 수신 대기 상태예요.
삽질했던 3가지

여기서부터가 본론이에요. 돌아가기까지 밟은 함정을, 고친 순서가 아니라 효과가 컸던 순서로 정리했어요.
삽질 1: redaction을 작성했는데도 identity가 제거되지 않는다
transform의 context: datapoint는 이름 그대로 데이터포인트 계층의 속성을 대상으로 해요. 그런데 OpenTelemetry의 관습에서는 user.*나 organization.* 같은 속성은 **리소스 계층(resource attributes)**에 놓이는 게 정석이에요.
즉 "datapoint의 속성을 지운다"는 문장을 아무리 늘어놔도, 리소스 계층에 붙은 identity는 그대로 통과해요.
합성 데이터로 검증했을 때 이게 눈에 보였어요. 데이터포인트에 붙인 user.account_uuid는 제대로 지워졌는데, 리소스에 붙인 user.email은 생 JSONL에 그대로 남아 있었어요.
// events.jsonl의 한 줄(발췌) — datapoint의 uuid는 지워졌지만…
"resource": { "attributes": [
{ "key": "service.name", "value": {"stringValue": "claude-code"} },
{ "key": "user.email", "value": {"stringValue": "***@***"} } // ← 남아 있다
]}
이 시점에서 알게 된 건 다음 일반 원칙이에요.
datapoint 계층만 대상으로 한 transform은 resource 계층의 속성을 삭제하지 않는다.
교훈은 단순해요. "어느 계층에 붙은 속성을 지우고 싶은지"를 먼저 확인하고 나서 transform을 작성한다. resource 계층도 떨구고 싶다면 context: resource 문장을 별도로 더해줘야 해요. redaction은 "작성했다는 착각"이 가장 위험해요.
다행이었던 건, 취합 쪽 SQLite를 화이트리스트 스키마로 해둔 거예요. 생 JSONL에 identity가 남아도, DB 컬럼에 user.email을 받을 자리가 없으니 분석 대상에는 유입되지 않아요. 게다가 생 JSONL 자체는 .gitignore 처리되어 로컬 밖으로 나가지 않아요. "수집 쪽 redaction"과 "저장 쪽 스키마"의 2단 구조로 해두면, 한쪽의 누락을 다른 쪽이 받아내 줘요. 그렇다곤 해도 transform의 누락은 버그니까, 고칠 건 고쳐요.
솔직하게 덧붙이면, "실제 Claude Code가 identity를 resource 계층에 붙이는지 datapoint 계층에 붙이는지"는 프로덕션 데이터를 흘려서 최종 확인해야 할 부분이에요. 합성 데이터로 알 수 있었던 건 "datapoint만 지우는 transform은 resource 계층을 놓친다"는 일반적 사실까지예요. 여기는 단정하지 말고, 자기 텔레메트리 소스가 어느 쪽에 붙이는지 반드시 자기 눈으로 확인하시길 바라요.
삽질 2: Collector 재시작으로 JSONL이 truncate된다
file exporter는 기본값이면 기동할 때마다 파일을 truncate해요. systemd의 Restart=on-failure와 조합되면, Collector가 죽고 재시작할 때마다 events.jsonl이 빈 상태로 돌아가요.
이것만으로도 아프지만, 취합 쪽이 "몇 번째 줄까지 읽었는지"를 커서로 기억하는 설계면 2차 피해가 나요.
Collector 재시작
↓
events.jsonl이 비워짐
↓
취합 쪽 커서는 이전 줄 수를 기억한 채
↓
"거기까지 이미 읽었다"고 오인
↓
재시작 이후 텔레메트리를 영구적으로 건너뜀
대책은 exporter에 append: true를 더하는 것뿐이에요. 고친 뒤 수동으로 Collector를 재시작하고, (1) 재시작 전 줄이 파일에 남아 있는지, (2) 재시작 후 데이터가 끝에 추가되는지, (3) SQLite 취합 커서가 연속해서 진행되는지 3가지를 확인해 두면 안심돼요. 이 한 줄을 빠뜨리면 증상이 "가끔 데이터가 날아간다"는 재현하기 어려운 형태로 나와요. 데이터 누락은 나중에 알아채도 구멍이 메워지지 않으니, 구현 시 코드 리뷰에서 일찌감치 잡아두고 싶은 부류의 버그예요.
삽질 3: 설정을 넣어도 '지금 이 세션'에는 효과가 없다
~/.claude/settings.json에 telemetry 환경 변수를 넣어도, 이미 기동 중인 세션에서는 텔레메트리가 나오지 않아요. 환경 변수는 기동 시점에 읽히니까, 효과가 있는 건 다음에 새로 띄우는 신규 세션부터예요.
당연하다면 당연하지만, "설정했으니 바로 확인해 보자" 하고 봤는데 데이터가 한 건도 안 와서, 엉뚱하게 Collector나 설정을 의심하다 시간을 날리기 쉬워요. 원인 분리에서 헤매지 않도록 확인 순서를 정해두면 좋아요.
- Collector를 기동한다
settings.json을 갱신한다- 기존 Claude Code 세션을 종료한다
- 새 세션을 기동한다
- JSONL 출력을 확인한다
파이프라인이 살아 있는지는 실제 세션을 기다리지 않고 다음 방법으로 분리하는 게 빨라요.
파이프라인 검증은 합성 데이터로

계측 기반 검증에서 굳이 과금되는 실제 Claude Code 세션을 돌릴 필요는 없어요. 확인하고 싶은 건 "모델이 어떻게 행동하는가"가 아니라 "wire format이 통과하는가(수신 → 변환 → 기록 → 취합)"라는 배관 이야기니까요.
할 일은 OTLP/HTTP 엔드포인트에 손으로 1발 POST하는 것뿐이에요.
curl -s -X POST http://localhost:4318/v1/metrics \
-H "Content-Type: application/json" --data @otlp_smoke.json
# → {"partialSuccess":{}} / HTTP 200
otlp_smoke.json에는 claude_code.session.count를 1건, 일부러 account identity 속성을 섞어서 넣어둬요. resource 계층과 datapoint 계층 양쪽에 심어두면, 어느 계층에서 놓치고 있는지까지 한 번에 분리할 수 있어요(실제 데이터가 아니라 검증용 고정 문자열을 씀).
{
"resourceMetrics": [{
"resource": {
"attributes": [
{ "key": "user.email", "value": { "stringValue": "SHOULD_BE_REMOVED" } }
]
},
"scopeMetrics": [{
"metrics": [{
"name": "claude_code.session.count",
"gauge": { "dataPoints": [{
"asInt": "1",
"attributes": [
{ "key": "user.account_uuid", "value": { "stringValue": "SHOULD_BE_REMOVED" } },
{ "key": "session.id", "value": { "stringValue": "synthetic-session" } }
]
}]}
}]
}]
}]
}
이렇게 하면 이 1번의 POST로 다음을 동시에 확인할 수 있어요.
- Collector가 OTLP를 수신할 수 있는가
- transform으로 identity가 삭제되는가(resource / datapoint 양 계층 모두)
- JSONL로 출력되는가
- SQLite에 올바르게 취합되는가
- 화이트리스트 밖 속성이 DB로 들어가지 않는가
events.jsonl의 끝과 취합 후 SQLite를 보면, 배관 전 구간을 한 번에 검증할 수 있어요. 삽질 1(identity가 남는다)을 알아챌 수 있었던 것도 이 합성 테스트 덕분이었어요.
tail -n 1 data/otel_raw/events.jsonl # SHOULD_BE_REMOVED이 남아 있지 않은지
SELECT event_type, session_id, value
FROM events
WHERE session_id = 'synthetic-session';
-- session.count | synthetic-session | 1 이 반환되면 취합 OK
PRAGMA table_info(events);
-- user.email / user.account_uuid 용 컬럼이 존재하지 않는 것도 확인
실제 데이터를 기다리는 것보다 빠르고, 과금도 안 되고, 깨졌을 때 어느 구간에서 깨졌는지 분리하기 쉬워요. 계측계를 만들 때의 정석으로 추천해요.
덤: 리뷰는 '빠뜨린 코드'에도 효과가 있었다
여담이지만, 이번 redaction 구현은 첫 패스에서 transform 프로세서를 통째로 빠뜨렸어요. 사전 검증에서 "identity는 전 시그널에 상시 붙는다"고 알고 있었는데, 설정에 반영하는 걸 잊었어요. 이걸 잡아낸 건 이 프로젝트에서 로컬 Codex CLI로 돌리는 코드 리뷰 공정이었어요.
코드 리뷰라고 하면 "작성한 코드의 흠집 찾기"를 떠올리기 쉽지만, 계측 기반처럼 "설정=코드"인 영역에서는 "작성했어야 하는데 작성되지 않은 코드"의 검출이 오히려 효과가 커요. append: true를 빠뜨린 것도 transform을 빠뜨린 것도, 둘 다 같은 "빠뜨림" 유형의 버그였어요.
결국 어떻게 됐나
| 항목 | 상태 |
|---|---|
| metrics 수집 | 가동 중. 다음 신규 세션부터 session.count / token.usage / cost.usage / code_edit_tool.decision이 SQLite에 쌓여 간다 |
| 도입 전 스냅샷 | 별도로 확보 완료. 데이터가 쌓이면 전후 비교(정말로 운영이 가시화됐는지)가 가능하다 |
| 잔여 태스크① | resource 계층의 redaction을 실제 데이터로 최종 확인해 막는다 |
| 잔여 태스크② | 쌓인 지표를 Markdown 게이지 리포트로 떨군다 |
OpenTelemetry의 좋은 점은, 이런 "무거운 백엔드를 세울 정도는 아니지만 생 로그보다는 구조화해서 지속적으로 쌓고 싶다"는 어중간한 요구에, 수신과 변환 부분만 표준화된 부품으로 얹혀준다는 거예요. 출력 대상은 SQLite든 Grafana든 나중에 갈아끼울 수 있어요. 우선 배관을 통하게 하고, 필요해지면 넓힌다 — 이 순서를 잡기 쉽다는 게 실감이었어요.
정리
- Claude Code의 운영 지표는 OTLP로 그대로 뽑을 수 있다. 백엔드는 가벼워도 된다.
- redaction은 어느 계층의 속성인지를 확인하고 나서 작성한다. 놓칠 것을 전제로 저장 쪽 스키마도 좁힌다.
fileexporter는append: true. 커서 방식 취합과 truncate는 궁합이 최악이다.- 설정은 다음 세션부터. 배관 검증은 합성 OTLP로.

참고:
- Claude Code monitoring & usage docs — 표준 속성,
OTEL_METRICS_INCLUDE_ACCOUNT_UUID,OTEL_LOG_*콘텐츠 플래그- OpenTelemetry Collector — transform processor
- OpenTelemetry Collector — file exporter
- OTLP specification
이 글이 도움이 됐다면 추천해 주세요