
Claude Code의 에이전트 텔레메트리를 OpenTelemetry로 직접 측정하기 (그리고 삽질한 이야기)
요약
Claude Code의 세션 수, 토큰량, 비용 등 운영 지표를 OpenTelemetry(OTLP)를 통해 수집하고 SQLite에 저장하는 경량화된 계측 파이프라인 구축 방법을 다룹니다. 구현 과정에서 겪은 데이터 유실 및 컨텍스트 설정 오류 등 실무적인 시행착오와 해결 방안을 공유합니다.
핵심 포인트
- Claude Code의 OTLP 지원을 활용한 운영 지표(metrics) 수집 방법
- OTel Collector와 SQLite를 이용한 최소 구성의 경량 파이프라인 구축
- 계측 데이터 검증 시 합성 데이터를 활용하는 것이 안전하고 빠름
- 데이터 속성(attribute) 위치 및 Collector 설정 시 주의사항
Claude Code는
CLAUDE_CODE_ENABLE_TELEMETRY=1 설정을 통해 세션 수, 토큰량, 비용, 도구 판단과 같은 지표를 **OTLP로 정식 수출(export)**할 수 있다.
- 이를 중량급 백엔드(Grafana 스택 등)를 구축하지 않고, OTel Collector → JSON Lines → SQLite의 최소 구성으로 내구성(durable) 있게 받는 방법을 만들었다.
- 세 가지 삽질 포인트가 있었다. (1) redaction을 작성했음에도 account identity가 제거되지 않은 컨텍스트 계층의 착오, (2) Collector 재시작 시 파일이 truncate되어 결손, (3) 설정을 넣어도 '다음 신규 세션'부터만 적용되는 문제.
- 교훈: 계측 파이프라인의 검증은 과금이 발생하는 실제 세션을 돌리는 것이 아니라, 합성 OTLP 데이터로 돌리는 것이 빠르고 안전하다.
AI에게 코드를 작성하게 하는 운용을 계속하다 보면, 모델의 벤치마크보다 '우리들의 에이전트 운용이 실제로 어떻게 움직이고 있는가'가 더 궁금해진다. 하루에 몇 세션이 실행되었는지, 토큰과 비용은 어떻게 추이하고 있는지, 코드 편집 도구의 제안은 얼마나 수락/거절되고 있는지——이러한 운용 게이지는 per-task의 Markdown 리포트만 훑어봐서는 보이지 않는다.
다행히 Claude Code는 OpenTelemetry를 일급 시민(first-class)으로 지원하고 있어, 환경 변수 하나로 metrics / logs / traces(beta)를 내보낼 수 있다. 이번에는 우선 metrics만을 가능한 한 가벼운 상주형으로 지속 수집하는 기반을 만들었다. 이 기사는 그 구현 절차와 도중에 빠졌던 함정들에 대한 기록이다.
계측 대상은 자사의 AI 워커 운용 리포지토리(여러 AI 에이전트가 동일한 머신을 공유하여 동작하는 구성)이다. 고유명사는 숨기지만, 구성 자체는 범용적이므로 읽고 재현할 수 있을 것이다.
이후의 이야기는 다음 4가지를 알고 있다면 읽을 수 있다. 반대로 이 부분이 모호하면 구현 파트가 '왜 이 설정을 쓰는지' 모르는 채 주문(spell)처럼 느껴질 수 있으므로, 먼저 정리해 두겠다.
OpenTelemetry (OTel): 애플리케이션이 내보내는 '메트릭스 (metrics, 수치 계측값)', '로그 (logs)', '트레이스 (traces, 처리 흐름의 기록)'를 특정 벤더에 종속되지 않는 공통 포맷으로 수집하기 위한 업계 표준. 대략 말하자면 '계측 데이터의 공통 언어'다. 이번에 사용하는 것은 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
▼
...
'왜 Prometheus + Loki + Grafana로 하지 않는가'라고 묻는다면, 상주 프로세스를 4개 늘리기 전에 애초에 지속 수집해서 무엇을 볼 수 있는지를 저렴하게 확인하고 싶었기 때문이다. 기존 운용이 Markdown 중심이었기에, 우선 SQLite에 저장한 뒤 나중에 집계하는 최소 구성부터 시작했다. 유용성이 확인되면 풀스택으로 확장한다는 단계다.
사용자 설정 (~/.claude/settings.json)에 환경 변수를 추가한다.
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
...
여기서 한 가지 판단이 있었다. 문서에는 OTEL_LOGS_EXPORTER도 있지만, 이번에는 포함하지 않았다. 뒤에서 설명할 Collector가 metrics 파이프라인만 가지고 있기 때문에, logs를 보내더라도 받는 쪽에서 갈 곳이 없어 에러가 발생할 뿐이기 때문이다. 최소 구성에서는 "보내는 신호"와 "받는 신호"를 일치시켜 두는 것이 정석이다.
참고로 프롬프트 본문, 응답 본문, 도구(tool) 입출력은 OTEL_LOG_* 계열 플래그가 모두 기본값 OFF이므로 출력되지 않는다. 이번에 수집하는 것은 건수, 토큰 수, 비용, 도구 판단과 같은 집계 지표(aggregate metrics)뿐이다.
otelcol-contrib (이번에는 v0.156.0)를 사용한다. 설정은 이것뿐이다.
receivers:
otlp:
protocols:
...
transform 프로세서에서 account identity를 삭제하는 것이 포인트다. 이는 사전 검증을 통해 알게 된 중요한 사실에 대응하는 것이다——Claude Code는 redaction 플래그 설정과 관계없이, user.email / user.account_uuid / user.account_id / organization.id를 모든 시그널(metrics 포함)에 표준 속성(standard attribute)으로 항상 부여한다. OTEL_LOG_* 플래그는 프롬프트/응답/도구 내용을 게이트(gate)할 뿐, 이 account identity에는 적용되지 않는다. 따라서 수집 측에서 명시적으로 삭제해야 한다.
(이 delete_key 작성 방식에는 함정이 있었다. 뒤에서 설명하겠다.)
Collector가 생성한 events.jsonl을 5분마다 읽어서 SQLite(경량 파일 기반 데이터베이스)에 upsert 하는 작은 Python 스크립트를 작성했다. 핵심은 스키마를 화이트리스트(whitelist)로 만드는 것이다. 즉, "넣어도 되는 항목만 미리 정해두고, 그 외의 항목은 저장 시점에 무시한다"는 설계다.
CREATE TABLE IF NOT EXISTS events (
event_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
...
필요한 컬럼만 가지므로, 설령 원본 JSON 측에 불필요한 속성이 남아 있더라도 분석 대상 DB에는 구조적으로 들어올 수 없다. redaction을 이중으로 해두는 발상이다 (뒤에서 설명할 삽질 덕분에 이 이중화가 효과를 발휘한다).
OTLP metric 이름은 자신들의 event_type으로 매핑한다.
METRIC_NAME_MAP = {
"claude_code.cost.usage": "cost.usage",
"claude_code.token.usage": "token.usage",
...
}
Collector와 수집 스크립트 모두 매번 수동으로 실행하는 것이 아니라, OS 부팅 시부터 백그라운드에서 계속 실행되게 하고 싶다. Linux에는 이를 위한 메커니즘(systemd)이 표준으로 포함되어 있으므로 이를 사용한다. Collector는 상주 서비스로, 수집은 5분 단위의 timer로 설정했다 (둘 다 자신의 사용자 권한으로만 동작하는 "user unit"이라는 설정 단위이다).
# collector.service (발췌)
[Service]
Type=simple
...
# ingest.timer (발췌)
[Timer]
OnBootSec=1min
...
systemctl --user daemon-reload
systemctl --user enable --now agent-telemetry-collector.service agent-telemetry-ingest.timer
이렇게 해서 Everything is ready. Begin running and processing data.가 뜨면 수신 대기 상태가 된다.
여기서부터가 본론이다. 작동시키기까지 겪었던 함정들을, 해결한 순서가 아니라 효과가 컸던 순서대로 정리한다.
transform의 context: datapoint는 그 이름 그대로 데이터 포인트(datapoint) 계층의 속성을 대상으로 한다. 그런데 OpenTelemetry의 관습에서는 user.*
나 organization.*와 같은 속성은 리소스 계층 (resource attributes) 에 두는 것이 관례다.
즉, "datapoint의 속성을 삭제한다"는 문장을 아무리 나열해도, 리소스 계층에 붙은 identity는 그대로 통과한다.
합성 데이터(synthetic data)로 검증했을 때 이 사실이 시각화되었다. 데이터 포인트(datapoint)에 붙인 user.account_uuid는 제대로 삭제되었지만, 리소스(resource)에 붙인 user.email은 원본 JSONL에 그대로 남아 있었다.
// events.jsonl의 한 줄 (발췌) — datapoint의 uuid는 삭제되었지만…
"resource": { "attributes": [
{ "key": "service.name", "value": {"stringValue": "claude-code"} },
...
이 시점에서 알 수 있었던 것은 다음과 같은 일반 원칙이다.
datapoint 계층만을 대상으로 한 transform은 resource 계층의 속성을 삭제하지 않는다.
교훈은 단순하다. "어느 계층에 붙어 있는 속성을 삭제하고 싶은가"를 먼저 확인한 후 transform을 작성해야 한다. resource 계층도 삭제하고 싶다면 context: resource 문을 별도로 추가해야 한다. redaction(비식별화)은 "작성했다고 생각하는 것"이 가장 위험하다.
다행이었던 점은 수집 측의 SQLite를 화이트리스트 스키마 (whitelist schema) 로 설정했다는 것이다. 원본 JSONL에 identity가 남아 있더라도, DB 컬럼에 user.email을 받을 공간이 없기 때문에 분석 대상에는 유입되지 않는다. 또한 원본 JSONL 자체는 .gitignore 처리가 되어 있어 로컬 외부로 나가지 않는다. "수집 측 redaction"과 "저장 측 스키마"라는 이중 구조를 갖춰두면, 한쪽에서 놓친 것을 다른 쪽이 받아줄 수 있다. 그렇다고 해서 transform의 누락은 버그이므로, 수정해야 할 것은 수정해야 한다.
솔직히 보충하자면, "실제 Claude Code가 identity를 resource 계층에 붙이는지 datapoint 계층에 붙이는지"는 실제 데이터를 흘려보내며 최종 확인해야 할 부분이다. 합성 데이터로 알 수 있었던 것은 "datapoint만 삭제하는 transform은 resource 계층을 놓친다"라는 일반적인 사실까지다. 이 부분은 단정 짓지 말고, 자신의 텔레메트리(telemetry) 소스가 어느 쪽에 붙이는지 반드시 직접 눈으로 확인하기 바란다.
file exporter는 기본 설정 상태에서 실행될 때마다 파일을 truncate(잘라내기)한다. systemd의 Restart=on-failure와 결합되면, Collector가 다운되어 재시작될 때마다 events.jsonl이 빈 파일로 돌아간다.
이것만으로도 뼈아프지만, 수집 측이 "몇 번째 줄까지 읽었는지"를 커서(cursor)로 기억하는 설계라면 2차 피해가 발생한다.
Collector가 재시작
↓
events.jsonl이 빈 파일이 됨
...
대책은 exporter에 append: true를 추가하는 것뿐이다. 수정 후에는 수동으로 Collector를 재시작하고, (1) 재시작 전의 행이 파일에 남아 있는지, (2) 재시작 후의 데이터가 끝에 추가되는지, (3) SQLite 수집의 커서가 연속적으로 진행되는지, 이 세 가지를 확인해 두면 안심할 수 있다. 이 한 줄을 누락하면 증상이 "가끔 데이터가 누락된다"는 식으로 재현하기 어려운 형태로 나타난다. 데이터 결손은 나중에 깨달아도 메울 방법이 없으므로, 구현 시 코드 리뷰 단계에서 조기에 잡아내야 하는 종류의 버그다.
~/.claude/settings.json에 telemetry 환경 변수를 넣더라도, 이미 실행 중인 세션에서는 텔레메트리가 나오지 않는다. 환경 변수는 실행 시점에 읽히므로, 적용되는 시점은 다음에 실행하는 새로운 세션부터이다.
당연한 이야기일 수도 있지만, "설정했다 → 바로 확인하자"라고 시도했다가 데이터가 한 건도 오지 않으면 Collector나 설정을 의심하며 시간을 허비하기 쉽다. 혼란을 방지하기 위해 확인 순서를 정해두는 것이 좋다.
- Collector를 실행한다
settings.json을 업데이트한다- 기존의 Claude Code 세션을 종료한다
- 새로운 세션을 실행한다
- JSONL 출력을 확인한다
파이프라인이 살아있는지 여부는 실제 세션을 기다리지 않고 다음 방법으로 빠르게 구분할 수 있다.
측정 기반을 검증할 때, 굳이 비용이 발생하는 실제 Claude Code 세션을 실행할 필요는 없다. 확인하고 싶은 것은 "모델이 어떻게 행동하는가"가 아니라, "wire format이 통하는가 (수신 → 변환 → 출력 → 수집)"라는 파이프라인(piping)의 문제이기 때문이다.
해야 할 일은 OTLP/HTTP 엔드포인트로 수동으로 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": {
...
이렇게 하면, 이 한 번의 POST로 다음 사항들을 동시에 확인할 수 있다.
- Collector가 OTLP를 수신할 수 있는가
- transform을 통해 identity가 삭제되는가 (resource / datapoint 양쪽 계층 모두)
- JSONL로 출력되는가
- SQLite에 올바르게 수집되는가
- 화이트리스트 외의 속성이 DB에 들어가지 않는가
events.jsonl의 끝부분과 수집 후의 SQLite를 확인하면, 파이프라인의 전 구간을 한 번에 검증할 수 있다. 함정 1(identity가 남는 문제)을 발견할 수 있었던 것도 이 합성 테스트(synthetic test) 덕분이었다.
tail -n 1 data/otel_raw/events.jsonl # SHOULD_BE_REMOVED가 남아있지 않은지 확인
SELECT event_type, session_id, value
FROM events
WHERE session_id = 'synthetic-session';
...
실제 데이터를 기다리는 것보다 빠르고, 비용도 발생하지 않으며, 문제가 생겼을 때 어느 구간에서 고장 났는지 분리하기 쉽다. 측정 시스템을 구축할 때의 정석으로 추천한다.
여담이지만, 이번 redaction 구현 과정에서 첫 번째 패스 때 transform 프로세서를 통째로 빠뜨리는 실수를 했다. 사전 검증을 통해 "identity는 모든 시그널에 항상 붙는다"는 것을 알고 있었음에도, 설정에 반영하는 것을 잊었던 것이다. 이를 잡아낸 것은 이 프로젝트에서 로컬 Codex CLI에 적용하고 있는 코드 리뷰 공정 덕분이었다.
코드 리뷰라고 하면 흔히 "작성한 코드의 잘못을 찾아내는 것"을 떠올리기 쉽지만, 측정 기반과 같은 "설정 = 코드"의 영역에서는 "작성했어야 하는데 작성되지 않은 코드"를 검출하는 것이 오히려 더 효과적이다. append: true를 빠뜨린 것이나 transform을 빠뜨린 것이나, 둘 다 똑같은 "작성 누락"형 버그였다.
| 항목 | 상태 |
|---|---|
| metrics 수집 | 가동 중. 다음 신규 세션부터 session.count / token.usage / cost.usage / code_edit_tool.decision이 SQLite에 쌓임 |
| ... |
OpenTelemetry의 장점은, "무거운 백엔드를 구축할 정도는 아니지만, 생로그(raw log)보다는 구조화하여 지속적으로 쌓고 싶다"는 애매한 요구사항에 대해, 수신과 변환 부분만을 표준화된 부품으로서 제공해 준다는 점이다. 출력 대상은 SQLite든 Grafana든 나중에 교체할 수 있다. 우선 파이프라인을 뚫어놓고, 필요할 때 확장해 나가는—이러한 순서를 잡기 쉽다는 것을 실감했다.
- Claude Code의 운영 지표는 OTLP로 그대로 가져올 수 있다. 백엔드는 가벼워도 좋다.
- redaction은 어느 계층의 속성인지를 확인한 후 작성한다. 누락될 것을 전제로 저장 측 스키마도 좁게 설정한다.
fileexporter는append: true를 사용한다. 커서(cursor) 방식의 수집과 truncate는 궁합이 최악이다.- 설정은 다음 세션부터 적용된다. 파이프라인 검증은 합성 OTLP로 수행한다.
참고:
- Claude Code 모니터링 및 사용 문서 — 표준 속성(Standard Attributes),
OTEL_METRICS_INCLUDE_ACCOUNT_UUID, 그리고OTEL_LOG_*
콘텐츠 플래그 - OpenTelemetry Collector — transform processor - OpenTelemetry Collector — file exporter
- OTLP 사양
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기