세션 데이터가 흐르는 길
세션 이벤트가 저장되는 곳, 비밀값을 가리는 곳, 내용을 볼 수 있는 사람, Langfuse 예제 수집기
worker가 엔진에서 받은 세션 데이터(모델 답변, 도구 호출과 결과, 권한 요청, 상태 변화)는 DB의 events 표로 간다. 이 표가 기능이 기대는 정본이다. 이 문서는 이 표가 무엇을 담고, 끊기면 무엇이 깨지는지, 비밀값을 어디서 가리는지, 누가 내용을 볼 수 있는지를 적고, Langfuse 수집기 예제를 띄우는 법을 적는다.
지금의 감시(stdout JSON 로그, /readyz, DB query를 Datadog Agent가 수집)는 Datadog 감시 기준에 있다. 트레이스는 운영자가 설정하면 표준 OTLP로 내보내고, 기본은 꺼짐이다(키와 켜는 법). 켜면 로그의 trace_id·span_id가 활성 span 안에서 채워진다.
한눈에
engine (Claude Agent SDK)
-> worker
-> API Worker Gateway (events)
-> PostgreSQL events table (source of truth)
-> SSE and Last-Event-ID reconnect, public API clients,
turn finalization, pending-request notificationevents 표
| 항목 | 내용 |
|---|---|
| 담는 것 | system·assistant·tool_use·tool_result·question·result·status·error 이벤트 |
| 쓰는 곳 | 공개 API의 SSE 클라이언트, turn 종료 판정, 대기 요청 알림 |
| 전달 | 같은 attempt 안에서 순서대로, 빈틈없이 저장해야 turn이 끝난다 |
| 끊기면 | 기능이 깨진다. SSE가 멈추고, turn을 finalize하지 못한다 |
| 내용 길이 | worker가 따로 자르지 않는다 |
| 받는 곳 | 설치한 PostgreSQL |
이벤트 종류와 payload 계약은 공개 API 스키마의 세션 이벤트 정의다. worker의 Claude adapter가 엔진 메시지를 이 이벤트로 바꾼다. assistant 메시지는 블록마다 이벤트 하나가 되고, 도구 결과 블록은 받은 그대로 tool_result 이벤트가 된다.
worker의 이벤트 전송 단계가 이벤트에 attempt 단위 source_sequence를 붙이고 Worker Gateway의 /append-events로 순서대로 보낸다. gateway가 받았다고 답하기 전에는 큐에서 지우지 않는다. API는 이벤트를 events 표에 쓴다. 같은 attempt의 같은 sequence는 유일 인덱스 events_attempt_sequence_uniq가 한 번만 받는다.
이 표에 기대는 기능은 다음과 같다.
- SSE와 재연결. API가
Last-Event-ID다음부터 이 표를 읽어 스트림으로 보낸다. 사용법은 API 유즈케이스 가이드의 5절에 있다. - turn 종료 판정. 앞 sequence가 비어 있으면
sequence_gap, finalize할 때 이벤트가 덜 저장됐으면events_incomplete로 거절한다. - 대기 요청 알림. 질문·권한 요청의
question이벤트와 세션의needs_input상태가 같은 트랜잭션에서 커밋된다. 그래서 클라이언트는 질문을 보기 전에needs_input을 받지 않는다. 답은pending_requests표에 남는다. worker가 요청을 어떻게 닫았는지도 같은 행에 남는다.settled_outcome은answered·expired·cancelled다.settled_reason은 worker가 취소한 이유(park·shutdown·interrupt·refused)다.settled_answer_result는 런타임이 답을 어떻게 받았는지(applied·stale·unknown)다. 사람이 거절한 요청은answered이고answer의decision이deny라서 취소와 구분된다. - 상태 전이. 복구·재개·일시정지·interrupt 같은 제어 단계가 상태와 함께 커밋된다.
- 클라이언트의 작업 완료 판단. 공개 API 클라이언트는
assistant·tool_use·tool_result이벤트를 읽고 일이 끝났는지 판단한다(API 유즈케이스 가이드).
비용과 사용량의 정본은 이벤트가 아니라 provider_usage 표다. egress proxy가 Messages 응답에서 사용량을 읽고, API가 이 표에 쓴다.
내용 수집과 비밀값 마스킹
무엇을 담나
이벤트는 내용을 담는다. assistant 텍스트, 도구 입력(tool_use), 도구 결과 본문(tool_result), 질문과 권한 요청의 입력이 들어간다.
엔진(Claude Code CLI) 자체의 telemetry는 켜지 않는다. 엔진에 넘기는 환경 변수는 allowlist이고, OTel 변수는 하나도 넘기지 않는다.
비밀값을 어디서 가리나
지금 규칙은 세 겹이다.
- 엔진 메시지. Claude adapter의 메시지 정리 단계가 엔진 메시지마다 민감한 키의 값,
Bearer …같은 비밀 모양의 값,password=…같은 이름 붙은 값을[REDACTED]로 바꾼다. 이벤트는 이 결과로 만든다. - claim이 준 비밀값. worker의 비밀값 가리기 단계가 이 attempt에 받은 비밀값 자체를 찾아
<redacted>로 바꾼다. 이 처리는 이벤트 전송과 대기 요청 등록에 걸린다. 엔진이 base64처럼 다시 인코딩한 값은 잡지 못한다. - 프로세스 로그. 로그 가리기 규칙이 API·worker의 stdout 로그를 가린다. egress proxy는 같은 규칙의 사본을 쓴다. 규칙 목록은 API 서버의 'provider 키와 저장소 자격 증명은 worker에 가지 않는다' 절에 있다. 이 규칙은 로그용이고 이벤트에는 걸리지 않는다.
1과 3의 패턴 규칙은 가리는 범위가 다르다. 로그 규칙 sanitizeText는 비밀 모양이나 token= 같은 조각을 하나라도 보면 문자열 전체를 [REDACTED]로 바꾼다. mapper는 대부분 일치한 구간만 바꾸고, name=value 꼴에서 값의 끝이 분명하면 값만 가린다. 그러나 mapper의 hideNamedValues도 password: …처럼 콜론으로 이어지는 값이나 끝을 가를 수 없는 값을 보면 텍스트 전체를 가린다. 그래서 password:가 든 YAML 파일을 Read한 도구 결과는 이벤트에서 통째로 사라진다. 두 파일의 패턴 목록도 서로 다르다.
누가 내용을 보나
세션 이벤트는 sessions:read scope를 가진 주체가 그 세션을 볼 수 있을 때만 읽는다. 먼저 세션이 주체와 같은 범위에 있어야 한다. 사람은 자기 workspace, API key는 그 key의 owner다. 그 안에서 세션의 공유 범위가 workspace(한 번도 바꾸지 않은 세션의 기본값)이면 workspace 구성원 모두가 읽고, people·team이면 만든 사람과 지정된 사람·팀 구성원만 읽는다. 개인 key는 그 key를 만든 사람으로 판정한다. 에이전트 key는 구성원이 아니라서 key 자신으로 판정하고(그 에이전트의 세션으로 한정된다), 자기가 만든 세션(만든 사람이 없는 세션)에서는 만든 사람 자리에 선다. 볼 수 없는 세션은 404다. 공유 범위를 바꾸는 법과 owner·admin의 감사 열람은 API 서버의 '세션 공유 범위와 감사 열람' 절에 있다. scope는 HTTP 경계에서 검사한다.
보존 기간
events의 내용은 지우지 않는다. 오래 쉰 세션의 행은 object store로 옮겨지고, API는 그 object에서 같은 응답을 읽는다(API 유즈케이스 가이드의 410 CURSOR_EXPIRED 설명). 보존 기간 정책은 아직 없다. 이벤트 말고 플랫폼이 남기는 데이터 전체의 위치·보존 기간·삭제 조건과 세션 하나를 따라가는 확인 절차는 플랫폼이 보존하는 데이터에 있다.
Langfuse 예제 수집기 띄우기
infra/observability/에 수집기 예제가 두 개 있다. Grafana LGTM을 띄워 trace를 찾는 법은 관측 (OTel)에 있고, 여기서는 Langfuse 예제를 다룬다. 두 예제 모두 Werft 스택과 따로 뜨는 compose project이고, 앞에 OpenTelemetry Collector를 하나씩 두어 표준 OTLP/HTTP를 받는다. 한 컴퓨터에서 OTLP 수신을 확인하는 용도이고, 운영 권장 구성이 아니다. 어느 수집기를 쓰고 어떻게 운영할지는 운영자가 정한다.
볼 수 있는 범위. 이 절의 예제는 내보내기를 켜지 않은 상태를 전제로 한다. 내보내기를 켜면 Werft가 trace를 보내지만, 이 절에서 보내는 것은 아니다. 이 절로 확인할 수 있는 것은 예제 수집기가 OTLP trace를 받아 화면에 보여 주는 데까지이고, 보내는 trace는 send-test-span.sh가 만든 가짜 span이다.
| 예제 | 디렉터리 | OTLP/HTTP 받는 곳 | 화면 | 받는 신호 |
|---|---|---|---|---|
| Langfuse | infra/observability/langfuse/ | http://127.0.0.1:25318 | http://localhost:25000 | 트레이스 |
| Grafana LGTM | infra/observability/lgtm/ | http://127.0.0.1:26318 | http://localhost:26000 | 트레이스, 로그, 메트릭 |
포트는 모두 loopback에만 열리고 기본 스택(3000, 3001, 5432)과 겹치지 않는다. 데이터 흐름은 보내는 쪽 → collector(collector.yaml) → Langfuse 또는 grafana/otel-lgtm이다. 수집기 credential은 collector만 갖는다. Langfuse compose 안의 비밀값 기본값(local-…로 시작하는 값과 예시 키 쌍)은 로컬 확인용 자리표시자다. 이 컴퓨터 밖에서 쓰려면 다음 변수를 모두 새 값으로 넘긴다. LANGFUSE_POSTGRES_PASSWORD, LANGFUSE_CLICKHOUSE_PASSWORD, LANGFUSE_REDIS_PASSWORD, LANGFUSE_MINIO_PASSWORD, LANGFUSE_SALT, LANGFUSE_ENCRYPTION_KEY(openssl rand -hex 32로 만든 64자), LANGFUSE_NEXTAUTH_SECRET, LANGFUSE_INIT_USER_EMAIL, LANGFUSE_INIT_USER_PASSWORD. 비밀번호와 계정은 볼륨을 처음 만들 때 정해지므로, 이미 띄운 뒤라면 down -v로 지우고 다시 띄운다. collector의 키 쌍은 아래 절처럼 따로 바꾼다.
띄우고 내리기
저장소 루트에서 실행한다. Langfuse는 처음 뜰 때 DB migration 때문에 1~2분 걸린다.
docker compose -p werft-obs-lgtm -f infra/observability/lgtm/compose.yml up -d --wait
docker compose -p werft-obs-langfuse -f infra/observability/langfuse/compose.yml up -d --wait내릴 때는 아래를 실행한다. -v는 저장한 트레이스까지 지운다.
docker compose -p werft-obs-lgtm -f infra/observability/lgtm/compose.yml down -v
docker compose -p werft-obs-langfuse -f infra/observability/langfuse/compose.yml down -v테스트 span 보내기
infra/observability/send-test-span.sh로 가짜 트레이스를 하나 보낸다. werft.turn 밑에 werft.llm_request와 werft.tool이 있고, turn span에는 사용자 입력을, 모델 응답 span에는 답변 텍스트를 gen_ai.* 속성으로 싣는다. --with-log를 붙이면 같은 trace에 묶인 로그 한 줄도 보낸다. Langfuse 예제의 collector는 로그를 받지 않으므로 Langfuse에는 붙이지 않는다.
infra/observability/send-test-span.sh http://127.0.0.1:26318 --with-log
infra/observability/send-test-span.sh http://127.0.0.1:25318출력의 trace_id와 session_id로 아래 화면에서 찾는다.
Langfuse 프로젝트 키 만들기
Langfuse 예제는 처음 뜰 때 LANGFUSE_INIT_* 설정으로 조직 Werft local, 프로젝트 werft-local, 예시 키 쌍, 로그인 계정을 만든다. 로그인 계정의 기본값은 이메일 admin@example.com과 비밀번호 local-password이다. collector는 같은 예시 키 쌍으로 Langfuse에 보낸다.
다른 키를 쓰려면 다음과 같이 한다.
http://localhost:25000에 로그인하고 Settings > API Keys > Create new API keys로 키 쌍을 만든다.- 두 값을 환경 변수로 넘겨 collector만 다시 만든다. 값을 파일에 적지 않는다.
LANGFUSE_PUBLIC_KEY='<public key>' LANGFUSE_SECRET_KEY='<secret key>' \
docker compose -p werft-obs-langfuse -f infra/observability/langfuse/compose.yml up -d --no-deps otel-collector화면에서 찾기
Langfuse. http://localhost:25000에 예제 계정으로 로그인하고 Tracing에서 trace id로 찾는다. 바로 열려면 http://localhost:25000/project/werft-local/traces/<trace_id>로 간다. 세션 단위로 보려면 Sessions에서 session_id를 찾는다. 예제의 테스트 span으로 Langfuse 4.66.0에서 확인한 매핑은 다음과 같다.
| span 속성 | Langfuse 화면 |
|---|---|
gen_ai.input.messages | Input |
gen_ai.output.messages | Output |
session.id | Sessions |
gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens | Generation 종류의 관측. 모델 이름과 토큰 수가 붙는다 |
gen_ai.operation.name=execute_tool, gen_ai.tool.name | Tool 종류의 관측. 이름은 도구 이름이다 |
그 밖의 속성(werft.*, resource 속성) | Metadata |
Grafana. http://localhost:26000은 로그인 없이 열린다. 트레이스는 Explore → Tempo → TraceQL에 trace id를 넣어 찾는다. 한 세션의 turn을 모두 보려면 { span.werft.session_id = "<session_id>" }로 검색한다. 로그는 Explore → Loki에서 {service_name="werft-api"}로 찾는다.