traceparent 헤더 완전 정리: W3C Trace Context 가이드
traceparent 헤더는 요청이 거쳐 가는 모든 서비스에 그 요청의 신원을 실어 나르는 한 줄짜리 ASCII 문자열입니다. 분산 추적이 서비스 경계를 넘어 이어지는 것도 이 한 줄 덕분입니다. 현재 버전에서는 길이가 정확히 55자이고, 대시로 구분된 필드 네 개로 이루어집니다.
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └─ trace-flags (2 hex, 1 byte)
│ │ └─ parent-id (16 hex, 8 bytes)
│ └─ trace-id (32 hex, 16 bytes)
└─ version (2 hex, 1 byte)
이 중 두 필드는 요청이 이동하는 동안 서로 다르게 동작합니다. trace-id는 모든 홉에서 그대로 유지됩니다. 엣지 프록시부터 마지막 데이터베이스 호출까지, 이 값이 곧 요청의 이름입니다. 반면 parent-id는 홉마다 바뀝니다. 요청이 아니라 나를 호출한 스팬을 가리키기 때문입니다. ‘내 트레이스가 이상하게 보인다’는 티켓의 상당수가 이 둘을 혼동한 데서 나옵니다.
여기까지가 구조입니다. 어려운 쪽은 필드 표에 나오지 않습니다. 어떤 헤더가 무효인지, 사양을 준수하는 수신자는 무효 헤더를 받으면 어떻게 하는지, 둘 다 추적을 지원한다고 말하는 두 서비스 사이에서 헤더가 왜 조용히 사라지는지 같은 것들입니다. 지금 눈앞에 실제 헤더가 있다면 무료 traceparent 디코더에 붙여 넣고 함께 읽어 보세요. 필드를 나누고 플래그 바이트를 비트 단위로 펼쳐 보여 줍니다. 헤더가 깨져 있다면 어떤 규칙을 위반했는지도 알려 줍니다.
한눈에 보는 traceparent 헤더
traceparent 헤더는 하나의 트레이스를 서비스에서 서비스로 실어 나르는 HTTP 헤더 한 줄입니다. 대시로 구분된 16진 필드 version, trace-id, parent-id, trace-flags 네 개로 이루어지고, 현재 버전 기준 길이는 정확히 55자입니다. trace-id는 요청 전체를 가리키고, parent-id는 나를 호출한 스팬을 가리킵니다.
| 필드 | 16진 자릿수 | 바이트 | 무엇을 식별하나 | 홉마다 바뀌나 |
|---|---|---|---|---|
version | 2 | 1 | 나머지가 따르는 포맷. 오늘날에는 언제나 00 | 아니요 |
trace-id | 32 | 16 | 요청 전체, 처음부터 끝까지 | 아니요 |
parent-id | 16 | 8 | 나를 호출한 스팬(호출자의 스팬 ID) | 예 |
trace-flags | 2 | 1 | 8비트 필드. 비트 0이 sampled | 드물게 |
16진 문자 52개에 대시 세 개를 더하면 55자가 됩니다. 이 숫자는 외워 둘 만합니다. 버전이 00인 헤더가 이 길이를 벗어나면 무효이고, 길이는 눈으로 가장 빨리 확인할 수 있는 항목이기 때문입니다.
헤더에 담기는 값은 전부 소문자 16진수입니다. ‘대소문자를 가리지 않는 16진수’가 아니라 소문자입니다. W3C Trace Context 권고안의 문법은 0-9와 a-f만 허용하고 그 밖의 문자는 받지 않습니다. 값 자체는 멀쩡한데 대문자로 적힌 trace ID가 하류에서 그대로 버려지는 이유가 여기에 있습니다.
필드별로 뜯어보기
필드마다 폭이 정해져 있고, 무효로 판정되는 값과 어긋나는 방식도 제각각입니다.
version: 언제나 ‘그냥 00’인 것은 아닙니다
오늘날 버전 바이트는 00이고 당분간은 계속 00일 것입니다. 다만 ff는 명시적으로 금지되어 있습니다. 사양이 이 값을 무효로 예약해 두었기 때문에, ff로 시작하는 헤더는 뒤에 무엇이 오든 도착하는 즉시 폐기됩니다.
정작 흥미로운 규칙은 한 번도 본 적 없는 버전에 대한 것입니다. if (version !== '00') reject() 같은 파서는 틀렸고, 그 대가도 작지 않습니다. 사양은 버전이 더 높고 헤더 길이가 알려진 포맷 이상이라면 수신자가 파싱을 시도하도록 요구합니다. 아는 필드만 읽고, 뒤에 남은 여분의 데이터는 너그럽게 넘기고 계속 진행하라는 것입니다. 대신 거부해 버리면, 상류의 누군가가 버전을 올리는 순간 내 서비스가 기존 추적이 끊기고 새 추적이 시작되는 경계가 됩니다.
// Wrong: makes your service the place traces go to die
if (version !== '00') throw new Error('bad traceparent');
// Right: parse the prefix you understand
if (version !== '00' && header.length >= 55) {
// read version, trace-id, parent-id, trace-flags; ignore the rest
}
trace-id: 16바이트, 요청 전체의 신원
소문자 16진 문자 32개이고, 트레이스가 살아 있는 동안 변하지 않습니다. 맨 처음에 어느 서비스가 생성했든 모든 홉이 그대로 복사해 전달합니다. 관측 백엔드에서 트레이스를 검색할 때 붙여 넣는 문자열이 바로 이 값입니다.
값을 규정하는 규칙은 두 가지입니다. 16진 문자 32개여야 하고, 전부 0이어서는 안 됩니다. 00000000000000000000000000000000은 ‘아직 데이터가 없는 트레이스’가 아닙니다. 사양이 무효 값으로 못 박아 두었고, 수신자는 헤더 전체를 무시해야 합니다. 실무에서 전부 0인 trace ID는 초기화되지 않은 SDK이거나, 전달할 실제 컨텍스트가 없어서 자리 표시자를 끼워 넣은 미들웨어를 뜻합니다.
trace-id는 UUID와 같은 128비트 폭이지만 UUID는 아닙니다. 버전 비트도, variant 비트도, 대시도, 어떤 구조도 없는 불투명한 16바이트입니다. 여기서 v4를 파싱해 낼 수 없고, 대시를 뗀 UUID가 자동으로 유효한 trace-id가 되는 것도 아닙니다. 버전과 variant 니블 때문에 무작위성이 균일하지 않기 때문입니다. UUID가 그 128비트 안에 실제로 무엇을 예약해 두는지 궁금하다면 UUID가 실제로 담고 있는 것이 레이아웃을 하나씩 짚어 주고, UUID 생성기는 버전과 variant 비트가 어디에 놓이는지 눈으로 보여 줍니다.
parent-id: 8바이트, 나를 호출한 스팬
16진 문자 16개이고, 홉마다 새로 쓰입니다. 이 필드는 이름 때문에 실제보다 훨씬 많은 혼란을 부릅니다. W3C 사양은 parent-id라 부르고 OpenTelemetry는 같은 8바이트를 스팬 ID라 부르는데, 하나를 양쪽에서 바라본 이름일 뿐입니다. 내 서비스 관점에서는 부모이고, 호출자 관점에서는 방금 아웃바운드 요청을 위해 생성한 스팬의 ID입니다.
그래서 서비스 A가 서비스 B를 호출하면 A는 자기 자신의 스팬 ID를 parent-id 자리에 넣습니다. B는 자식 스팬을 생성하고, B가 C를 호출할 때는 그 자리에 B의 스팬 ID를 넣습니다. trace-id는 처음부터 끝까지 그대로입니다. 전파 알고리즘은 이게 전부입니다.
parent-id가 전부 0인 경우도 trace-id와 같은 이유로 무효입니다. 0000000000000000은 호출자가 실제 스팬을 넘기지 않았다는 뜻이고, 이런 헤더는 어중간하게 받아들이지 말고 폐기해야 합니다.
trace-flags: 불리언처럼 보이지만 실제로는 8비트
실제로 마주치는 헤더는 거의 다 01로 끝나기 때문에 이 필드를 예/아니오로 읽기 쉽습니다. 하지만 이 필드는 한 바이트이고, 비트마다 역할이 정해져 있습니다.
- 비트 0, 마스크
0x01—sampled - 비트 1, 마스크
0x02—random-trace-id, Trace Context Level 2에서 추가 - 비트 2–7 — 예약됨. 수신할 때는 무시하고, 아웃바운드 요청에서는 0으로 비웁니다
조합별로 디코딩하면 이렇게 됩니다.
| 16진 | 2진 | sampled | random-trace-id | flags === 0x01이 성립하나? |
|---|---|---|---|---|
00 | 00000000 | false | false | false |
01 | 00000001 | true | false | true |
02 | 00000010 | false | true | false |
03 | 00000011 | true | true | false ← 여기가 버그 |
마지막 줄을 다시 보세요. 플래그가 03인 트레이스는 샘플링된 트레이스입니다. 바이트 전체를 01과 비교하는 코드는 이 트레이스를 샘플링되지 않은 것으로 보고합니다. 경고는 없고, Level 2 플래그가 켜진 일부 트래픽에서만 나타납니다. 추적하기 고약한 실패입니다. 파싱 버그가 아니라 샘플링 비율 문제처럼 보이기 때문입니다.
const flags = parseInt(traceFlags, 16);
// Wrong: treats a bit field as an enumeration
const sampled = traceFlags === '01';
// Right
const sampled = (flags & 0x01) !== 0;
const randomTraceId = (flags & 0x02) !== 0;
random-trace-id는 정확히 무엇을 보장할까요? trace-id의 최소한 오른쪽 7바이트가 균일한 무작위성으로 생성되었다는 사실입니다. 일관된 샘플링을 떠올리기 전까지는 학술적인 이야기로 들립니다. 하류 시스템이 트레이스의 1%를 남기고 싶고 모든 서비스가 독립적으로 같은 1%에 합의해야 한다면, ID를 먼저 해싱하지 않고 그 바이트들을 그대로 나머지 연산에 넣어도 됩니다. 이 플래그는 그렇게 해도 안전하다는 상류의 약속입니다.
traceparent가 무효가 되는 조건
버전이 00인 헤더의 거부 조건 전체는 다음과 같습니다. 벤더 문서에는 대개 흩어져 있거나 아예 빠져 있는 목록입니다.
| 증상 | 규칙 | 결과 |
|---|---|---|
00-4BF92F35...-01 | 문법은 소문자 16진수만 허용 | 무효 — 값은 맞지만 헤더는 거부됨 |
ff-... | 버전 ff는 사양이 금지 | 무효 |
trace-id가 00000000000000000000000000000000 | 전부 0인 trace-id는 사양이 명시한 무효 값 | 무효 |
parent-id가 0000000000000000 | 전부 0인 parent-id는 사양이 명시한 무효 값 | 무효 |
| trace-id가 16진 문자 32개가 아님 | 길이 고정 | 무효 |
| parent-id가 16진 문자 16개가 아님 | 길이 고정 | 무효 |
| trace-flags가 16진 문자 2개가 아님 | 길이 고정 | 무효 |
버전이 00인데 헤더가 정확히 55자가 아님 | 뒤에 붙는 데이터는 미래 버전에서만 합법 | 무효 |
0-9a-f와 대시를 벗어난 문자 | 16진수가 아님 | 무효 |
거부된 다음에 무슨 일이 벌어지는지가 더 중요합니다.
사양을 준수하는 수신자는 무효한 traceparent 헤더를 고쳐 쓰지도, 그대로 흘려보내지도 않습니다. 헤더를 폐기하고 새로 생성한 trace-id로 완전히 새로운 트레이스를 시작합니다.
그러니 화면에 보이는 증상은 깨진 트레이스 하나가 아닙니다. 서로 이어지지 않은 짧은 트레이스 두 개입니다. 하나는 잘못된 헤더를 내보낸 서비스에서 갑자기 끝나고, 다른 하나는 그 헤더를 받은 서비스에서 난데없이 시작된 것처럼 보입니다. 어디에도 오류로 표시되지 않습니다. 두 트레이스 모두 따로 떼어 놓고 보면 멀쩡합니다. 사람들은 그 사이의 잃어버린 연결을 찾느라 반나절을 쓰지만, 답은 미들웨어가 16진 문자열을 대문자로 변환해 버렸거나 손으로 조립한 헤더가 54자로 나왔다는 것입니다.
길이와 대소문자는 들여다본다고 보이지 않는 두 가지 실패 모드입니다. 헤더를 디코더에 붙여 넣으면 자릿수를 직접 세는 대신 어떤 규칙을 위반했는지 정확히 짚어 줍니다.
tracestate: 모두가 틀리는 동반 헤더
traceparent는 표준 신원을 실어 나릅니다. tracestate 헤더는 그 옆에 각 벤더가 덧붙이고 싶은 것을 쉼표로 구분한 key=value 멤버로 실어 나릅니다.
tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE
키를 알아보지 못하는 구현은 그 값을 손대지 않고 그대로 전달해야 합니다. 모든 홉이 내용을 이해하지 않아도 벤더가 표준 트레이스에 독자적인 상태를 얹어 보낼 수 있게 하려는 설계입니다.
다만 이 문법에는 실제 강제력이 있고, 그중 세 가지 규칙이 운영 환경에서 보는 증상을 설명해 줍니다.
리스트 멤버 32개가 절대 상한입니다. 권고가 아니라 문법 자체입니다. list = list-member 0*31( OWS "," OWS list-member ). 멤버가 33개인 tracestate는 항목이 하나 더 붙은 tracestate가 아니라 무효한 헤더이고, 수신자는 전체를 폐기할 권리가 있습니다. 달리 보면 설명이 되지 않는 증상의 답이 여기 있습니다. 엣지에도 있고 두 홉 뒤에도 있는데 다섯 번째 홉에서는 완전히 사라진 벤더 데이터 말입니다. 홉마다 자기 멤버를 덧붙였고, 리스트가 32를 넘었으며, 그때부터는 잘려 나가는 대신 헤더 전체가 버려진 것입니다.
값은 1자 이상 256자 이하이고, 절대 비어 있을 수 없습니다. 값 프로덕션은 공백이 아닌 문자로 끝나야 하므로 vendor=는 ‘값이 없는 키’가 아니라 문법 오류입니다. 출력 가능한 ASCII만 쓸 수 있고, 값 안에는 쉼표도 등호도 들어갈 수 없습니다.
키 문법은 Level 1과 Level 2 사이에서 달라졌습니다. Level 1은 @를 구조적 구분자로 쓰는 tenant@vendor 프로덕션으로 키를 정의했습니다. Level 2는 이를 평평한 문자 클래스로 교체했습니다. 키는 소문자나 숫자로 시작하고 뒤에는 a-z, 0-9, _, -, *, /, @가 이어집니다. Level 2에서 @는 평범한 문자이고, 키가 숫자로 시작해도 되며, Level 1 프로덕션이라면 거부했을 a@b@c도 완전히 합법적인 키입니다. Level 1로 검사하는 프록시와 Level 2 키를 내보내는 서비스가 함께 있으면 한쪽이 받아들이는 것을 다른 쪽이 거부하고, 헤더는 정확히 한 홉에서 사라집니다.
알아 둘 규칙이 두 가지 더 있습니다. 중복 키는 그 자체로 무효입니다. 그리고 traceparent의 parent-id를 수정할 때는 자신의 tracestate 항목을 리스트 맨 앞으로 옮겨야 합니다. 리스트는 최신 항목이 앞에 오는 순서이기 때문입니다. 맨 앞으로 옮기는 단계를 건너뛰면 오래된 벤더 상태가 그대로 남고, 읽는 쪽은 그것을 현재 값으로 취급합니다.
마지막은 너그러운 규칙입니다. 빈 리스트 멤버는 합법입니다. 미들박스가 항목을 제거하면서 쉼표만 남겨 rojo=1,,congo=2 같은 값이 나오는 일이 잦은데, 사양이 이를 명시적으로 허용합니다. 따라서 파서는 헤더가 잘못되었다고 선언하는 대신 빈 멤버를 버리고 계속 진행해야 합니다. 디코더의 tracestate 뷰는 멤버마다 유효성 검사 결과와 32개 제한 대비 누적 개수를 함께 보여 주므로, 쉼표를 세는 것보다 대개 빠릅니다.
분산 추적 헤더가 이동하는 경로: 요청 하나, 홉 네 개
엣지 프록시, API 서비스, 하류 서비스 두 개를 차례로 거치는 요청 하나를 따라가 봅니다.
Client
│ (no traceparent — the edge is the root)
▼
Edge proxy generates trace-id 4bf9…4736, span 00f0…02b7
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
▼
API service reads it, creates span a1b2c3d4e5f60718
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-a1b2c3d4e5f60718-01
▼
Orders service reads it, creates span 9f8e7d6c5b4a3928
│ traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-9f8e7d6c5b4a3928-01
▼
Inventory service
모든 홉이 하는 일은 세 가지로 같습니다. 인바운드 헤더를 읽고, 아웃바운드 호출마다 parent-id를 자기 스팬 ID로 교체하고, trace-id와 플래그는 그대로 전달합니다. 위 그림의 클라이언트처럼 인바운드 헤더가 아예 없으면 받는 서비스가 루트가 됩니다. 즉 trace-id를 생성하고 하류 전체에 적용될 샘플링 결정을 내립니다.
체인 전체를 시험해 보려면 헤더를 손으로 주입해도 됩니다.
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE' \
https://example.com/api
운영에서 캡처한 헤더를 스테이징에 재생하면 같은 trace-id가 백엔드에 나타나는 것을 볼 수 있습니다. 인증이나 본문을 붙여야 한다면 cURL 명령어 생성기가 플래그를 대신 조립해 주고, curl 명령어 치트시트는 디버깅할 때 필요한 헤더 옵션과 verbose 옵션을 다룹니다.
보냈다고 생각한 것이 아니라 서비스가 실제로 받은 것을 확인하려면, 일회용 에코 서버를 띄우고 홉 하나를 그쪽으로 향하게 하세요.
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class Echo(BaseHTTPRequestHandler):
def do_GET(self):
for name, value in self.headers.items():
print(f"{name}: {value}")
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok\n")
HTTPServer(("127.0.0.1", 8080), Echo).serve_forever()
PY
그다음 curl -H 'traceparent: …' http://127.0.0.1:8080/을 실행하고 반대편에서 나온 내용을 읽으면 됩니다. ‘프록시가 내 헤더를 삼킨다’는 조사의 절반은 여기서 끝납니다.
trace-flags sampled: 상류의 결정이지 보장이 아닙니다
trace-flags의 sampled 비트가 1이라는 것은 상류 서비스가 이 트레이스를 기록하기로 결정했다는 뜻입니다. 그 데이터가 백엔드까지 도달했다는 보장은 아닙니다.
헤드 기반 샘플링은 아무 일도 일어나기 전에 루트에서 결정을 내리고 아래로 전파합니다. 비용이 싸고 서비스 간에 일관되지만, 그 요청이 곧 실패하리라는 것을 미리 알 방법이 없습니다. 테일 기반 샘플링은 트레이스가 끝날 때까지 스팬을 버퍼에 모아 두었다가 결정하므로 오류가 포함된 트레이스를 전부 남길 수 있습니다. 대신 스팬을 메모리에 들고 있어야 하고, 모든 서비스의 스팬이 같은 컬렉터로 모여야 합니다.
테일 기반 샘플링에서는 모든 홉에서 01로 표시된 트레이스가 마지막에 버려질 수 있습니다. 레이트 리밋과 내보내기 할당량 때문에 버려지기도 합니다. 그러니 엣지에서 01인데 UI에 트레이스가 없다고 해서 반드시 전파 버그인 것은 아닙니다. 헤더를 들여다보기 전에 컬렉터 자체의 드롭 지표를 먼저 확인하세요.
일상적으로 더 자주 마주치는 것은 반대 경우입니다. 인바운드 플래그가 00이라면 호출자가 자기 샘플러를 돌린 뒤 기록하지 않기로 선택한 것입니다. 내 서비스의 설정에는 아무 문제가 없고, 내 샘플러를 아무리 들여다봐도 시간 낭비입니다. 물어야 할 것은 어느 상류 서비스가 샘플링하지 않기로 결정하고 있느냐입니다.
전파 포맷 간 변환
표준 경쟁에서는 W3C Trace Context가 이겼지만, 여전히 예전 포맷을 쓰는 시스템이 많고 게이트웨이가 그 사이를 번역합니다. 같은 traceparent 예제를 네 가지 포맷으로 적으면 이렇습니다.
| 포맷 | 헤더 | 예제 값 |
|---|---|---|
| W3C | traceparent | 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 |
| B3 단일 | b3 | 4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1 |
| B3 다중 | X-B3-TraceId, X-B3-SpanId, X-B3-Sampled | 4bf92f3577b34da6a3ce929d0e0e4736, 00f067aa0ba902b7, 1 |
| Datadog | x-datadog-trace-id, x-datadog-parent-id, _dd.p.tid 태그 | 11803532876627986230, 67667974448284343, 4bf92f3577b34da6 |
| AWS X-Ray | X-Amzn-Trace-Id | Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1 |
Datadog: 상위/하위 64비트 분할
Datadog의 식별자는 128비트 trace ID보다 먼저 만들어졌고, 그 호환용 장치가 바로 대부분의 변환이 어긋나는 지점입니다. x-datadog-trace-id는 하위 64비트를 10진 문자열로 실어 나릅니다. 상위 64비트는 _dd.p.tid 태그에 16진수로 따로 이동하고, 이 태그 자체는 x-datadog-tags 헤더에 실려 갑니다.
const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const [, traceId, parentId] = traceparent.split('-');
const datadogTraceId = BigInt('0x' + traceId.slice(16)).toString(10);
const higher64Hex = traceId.slice(0, 16);
const datadogParentId = BigInt('0x' + parentId).toString(10);
console.log('x-datadog-trace-id:', datadogTraceId); // 11803532876627986230
console.log('x-datadog-tags:', higher64Hex); // _dd.p.tid=4bf92f3577b34da6
console.log('x-datadog-parent-id:', datadogParentId); // 67667974448284343
고전적인 실수는 128비트 전체를 하나의 10진수로 변환하는 것입니다.
BigInt('0x' + traceId).toString(10);
// 100985939111033328018442752961257817910 — matches nothing in the UI
산술이 틀린 것은 아닙니다. 틀린 대상을 정확하게 10진수로 렌더링했을 뿐입니다. 그래서 코드 리뷰는 무사히 통과하고, 정작 어떤 트레이스와도 매칭되지 않습니다.
두 번째 함정은 수치 정밀도입니다. 64비트 식별자는 Number.MAX_SAFE_INTEGER인 9007199254740991을 넘기 때문에, trace ID를 자바스크립트(JavaScript) 숫자로 넘기는 코드 경로는 하위 자릿수를 망가뜨립니다. trace ID는 문자열로 유지하고 산술이 꼭 필요할 때만 BigInt를 꺼내세요. JSON에 따옴표 없이 도착한 ID는 눈으로 확인하는 시점에 이미 손상된 상태입니다.
AWS X-Ray: 존재하지 않는 타임스탬프
X-Ray trace ID는 1-{16진 8자}-{16진 24자} 모양이고, 앞의 16진 8자는 에폭 초 단위 생성 시각입니다. W3C에서 변환하는 절차는 기계적입니다.
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const epochHex = traceId.slice(0, 8); // 4bf92f35
const epochSeconds = parseInt(epochHex, 16); // 1274621749
const xrayId = `1-${epochHex}-${traceId.slice(8)}`;
// 1-4bf92f35-77b34da6a3ce929d0e0e4736
// X-Amzn-Trace-Id: Root=1-4bf92f35-77b34da6a3ce929d0e0e4736;Parent=00f067aa0ba902b7;Sampled=1
new Date(epochSeconds * 1000).toISOString(); // 2010-05-23T13:35:49.000Z
저 날짜를 보세요. 사양의 예제 헤더는 2010년 5월로 디코딩되는데, 누가 봐도 말이 되지 않습니다. W3C trace-id에는 타임스탬프가 들어 있지 않습니다. 무작위 16바이트 중 앞의 4바이트를 하나로 읽으면 그럴듯해 보이는 에폭 값이 얼마든지 나오지만, 그 식별자가 정말로 X-Ray에서 유래한 것이 아니라면 숫자에는 아무 의미가 없습니다. 임의의 trace-id에서 시각을 디코딩하는 것은 난수를 읽고 그 값을 믿는 일입니다.
정말로 X-Ray에서 온 ID라면 변환이 쓸모 있습니다. 그 16진 8자를 Unix 타임스탬프 변환기에 넣으면 읽을 수 있는 날짜가 나오고, 에폭 가이드는 그 뒤에 따라오는 초 대 밀리초, 시간대 함정을 다룹니다.
B3: Zipkin의 계보
B3는 Zipkin에서 나왔고 오래된 서비스 메시에서 마주치는 포맷입니다. 단일 헤더 형태는 traceId-spanId-sampled이고, sampled 필드가 16진 바이트가 아니라 1 또는 0입니다. 그래서 Level 2의 random-trace-id 비트는 들어갈 자리가 없어 변환 과정에서 그대로 사라집니다. 다중 헤더 형태는 같은 값을 X-B3-TraceId, X-B3-SpanId, X-B3-Sampled로 나눠 담습니다.
여기에 역사적인 사정이 하나 끼어드는데, 바로 자릿수 폭입니다. B3 trace ID는 64비트일 수 있고, 이는 16진 문자가 32개가 아니라 16개라는 뜻입니다. 64비트 B3 ID를 W3C로 변환하려면 왼쪽을 0으로 채워 32자리를 맞춰야 하고, 되돌릴 때는 자를지 말지를 정해야 합니다. 왼쪽 0 채우기는 안전하지만 자르기는 안전하지 않습니다. 상위 바이트만 다른 두 트레이스가 하나로 합쳐지기 때문입니다.
운영 환경에서 traceparent가 사라지는 곳
지금까지는 헤더가 도착한다는 전제였습니다. 도착하지 않는 경우도 많고, 사라지는 지점은 대체로 네 곳입니다.
브라우저가 교차 출처 호출에서 헤더를 떨어뜨립니다
증상: 프런트엔드 트레이스도 있고 백엔드 트레이스도 있는데 둘이 연결되지 않습니다. 또는 교차 출처 요청이 CORS 오류로 아예 실패합니다.
원인: traceparent는 커스텀 헤더이므로 이를 추가하면 요청이 단순 요청에서 벗어나 프리플라이트 OPTIONS가 발생합니다. 서버의 프리플라이트 응답이 Access-Control-Allow-Headers에 이 헤더를 나열하지 않으면 브라우저가 실제 요청을 차단합니다. 이와 별개로, OpenTelemetry의 브라우저 계측은 어떤 출처가 허용되는지 알려 주지 않는 한 교차 출처 요청에 추적 헤더를 주입하지 않습니다.
해결: 서버에서는 프리플라이트에 Access-Control-Allow-Headers: traceparent, tracestate를 반환합니다. 브라우저 SDK에서는 propagateTraceHeaderCorsUrls를 API 출처와 맞는 패턴으로 설정합니다. 둘 다 필요하고, 하나만 해서는 증상이 그대로입니다. 프리플라이트가 예상치 못한 상태 코드로 돌아왔다면 헤더 탓이라고 단정하기 전에 HTTP 상태 코드 치트시트와 대조해 볼 만합니다.
프록시, WAF, 로드 밸런서가 모르는 헤더를 제거합니다
증상: 서비스에 직접 curl할 때는 헤더가 있는데, 같은 요청이 게이트웨이를 거치면 사라집니다.
원인: 허용 목록 기반 전달입니다. 상당수의 프록시 설정, WAF 룰셋, 관리형 로드 밸런서는 자기가 아는 헤더만 전달하는데 traceparent는 기본 목록에 없습니다. 일부 메시는 헤더를 재작성해서 자기 trace-id를 생성하고 원래 값을 버리기도 합니다.
해결: 앞의 에코 서버로 이분 탐색을 하세요. 홉마다 차례로 뒤에 붙여 어느 계층이 헤더를 떨어뜨리는지 확인합니다. 그런 다음 그 계층의 전달 규칙에서 traceparent와 tracestate를 명시적으로 허용합니다. 프록시가 nginx라면, 라우트를 처리하는 블록이 어떤 헤더를 통과시킬지 결정하는데 그 블록이 항상 예상한 블록인 것은 아니라는 점을 기억하세요. Nginx location 우선순위 규칙이 헤더 설정이 통째로 무시된 것처럼 보이는 이유를 설명해 줍니다.
메시지 큐에는 HTTP 헤더가 없습니다
증상: 요청이 백그라운드 작업으로 넘어가는 순간 트레이스가 끊깁니다.
원인: 그 경계에는 HTTP 요청이 없으므로 헤더를 실어 보낼 대상 자체가 없습니다. Kafka에는 레코드 헤더가, SQS에는 메시지 속성이 있지만 둘 다 HTTP 계측이 알아서 채워 주지 않습니다.
해결: 프로듀서 쪽에서 컨텍스트를 메시지에 주입하고 컨슈머 쪽에서 추출합니다. 모든 OpenTelemetry SDK가 바로 이 용도로 inject와 extract를 노출하며, 와이어 포맷은 동일한 W3C 문자열입니다. 캐리어만 HTTP 헤더 맵에서 메시지 메타데이터로 달라질 뿐입니다. 언어별 캐리어 인터페이스는 OpenTelemetry 전파기 문서에서 다룹니다.
대소문자, 그리고 HTTP/2가 실제로 소문자로 바꾸는 것
증상: 코드 리뷰에서 Traceparent가 허용되는지를 두고 의견이 갈립니다.
원인: 서로 다른 두 규칙이 하나로 뭉뚱그려진 탓입니다. HTTP/1.1 헤더 이름은 대소문자를 가리지 않고, HTTP/2는 이름을 와이어에서 소문자로 인코딩하도록 요구합니다. 여기까지는 이름 이야기입니다. 이와 별개로 헤더 값 안의 16진수는 반드시 소문자여야 합니다. W3C 문법이 그렇게 정해 두었기 때문이고, 어떤 프로토콜 버전도 이 문제를 대신 해결해 주지 않습니다.
해결: 이름은 traceparent로 보내고 값은 절대 대문자로 쓰지 마십시오. 헤더 이름을 정규화하는 게이트웨이도 16진 자릿수까지 정규화해 주지는 않으며, 대문자 trace-id는 모든 전송 계층을 무사히 통과한 뒤 마지막에 파싱하는 애플리케이션에서 거부됩니다.
인바운드 traceparent를 신뢰해도 될까요?
공용 인터넷에서 도착한 traceparent는 사용자가 통제하는 입력입니다. 익명의 클라이언트가 고른 문자열이고, 대부분의 서비스는 별 의심 없이 받아들입니다.
여기서 구체적인 위험 세 가지가 따라옵니다. 첫째, 트레이스 스플라이싱입니다. 다른 곳에서 관찰한 trace-id를 보낸 공격자는 자기 요청을 기존 트레이스에 끼워 넣습니다. 그래프가 오염되고, 그 트레이스를 볼 수 있는 사람에게 내부 타이밍이 노출될 수 있습니다. 둘째, 할당량 소진입니다. 01을 하드코딩하면 모든 요청에 샘플링이 강제되어, 크지 않은 트래픽 폭주에도 수집 비용이 크게 뜁니다. 더 나쁜 경우에는 정작 필요했던 트레이스가 밀려납니다. 셋째, 테넌트 간 상관관계입니다. 서로 다른 테넌트의 요청에 하나의 trace-id를 재사용하면 도구가 하나의 논리적 작업으로 취급하는 레코드들이 서로 엮입니다.
현실적인 태도는 엣지에서 받되 믿지는 않는 것입니다. 문법을 유효성 검사해서 형식이 잘못된 헤더는 안쪽으로 넘기지 말고 거부하세요. 인증되지 않은 트래픽에 대해서는 인바운드 플래그를 존중하는 대신 자체 샘플링 결정을 다시 내려서, 외부 클라이언트가 내 샘플러를 ‘항상 기록’에 고정하지 못하게 하세요. 인증된 트래픽이라면 상대가 누구인지 알기 때문에 호출자의 결정을 존중해도 대개 괜찮습니다.
그리고 trace-id는 공개 정보로 취급하세요. 비밀이었던 적이 없습니다. 로그에도, 오류 페이지에도, 응답 헤더에도, 지원 티켓에 붙는 스크린샷에도 등장합니다. 사용자 ID나 테넌트 이름처럼 의미 있는 값을 절대 인코딩하지 말고, 인가 키로도 절대 쓰지 마십시오. 이것은 상관관계 식별자이고, 앞으로도 그 이상이어서는 안 됩니다.
FAQ
traceparent와 tracestate의 차이는 무엇인가요?
traceparent는 trace-id, parent-id, 샘플링 플래그로 이루어진 표준화된 신원을 실어 나르며, 모든 구현이 이를 이해해야 합니다. tracestate는 벤더별 상태를 실어 나르고, 이를 모르는 구현은 손대지 않고 그대로 전달합니다. 둘은 연결되어 있습니다. traceparent가 무효이면 사양은 tracestate도 함께 무시하도록 요구합니다.
호출 체인 중간에서 트레이스가 다시 시작되는 이유는 무엇인가요?
트레이스가 호출 체인 중간에서 다시 시작되는 것은 거의 항상 어느 한 홉이 문법을 통과하지 못한 헤더를 받아 폐기하고 새 trace-id를 생성했기 때문입니다. 대문자 16진수, 전부 0인 trace-id, 정확히 55자가 아닌 헤더가 모두 이 현상을 일으킵니다. 헤더 형식이 멀쩡하다면 그다음 용의자는 헤더를 제거하는 프록시와 실패한 교차 출처 프리플라이트입니다.
브라우저에서 traceparent를 보내려면 CORS 설정이 필요한가요?
그렇습니다. CORS 설정이 필요합니다. traceparent는 커스텀 헤더이므로 요청이 단순 요청에서 벗어나 프리플라이트가 발생하고, 서버는 Access-Control-Allow-Headers에 traceparent를 나열해야 합니다. 여기에 더해 OpenTelemetry의 브라우저 계측은 기본적으로 교차 출처에 추적 헤더를 주입하지 않으므로 propagateTraceHeaderCorsUrls 설정이 따로 필요합니다.
Kafka나 SQS로는 추적 컨텍스트를 어떻게 전파하나요?
프로듀서 쪽에서 traceparent 값을 Kafka 레코드 헤더나 SQS 메시지 속성에 쓰고, 컨슈머 쪽에서 다시 읽어 컨텍스트를 복원합니다. OpenTelemetry SDK는 모든 언어에서 이 용도의 inject와 extract를 노출합니다. 포맷은 그대로이고 캐리어만 HTTP 헤더 맵과 달라집니다.
trace ID를 로그나 응답에 노출해도 안전한가요?
그렇습니다. trace ID는 노출해도 안전합니다. 신원 정보가 박혀 있지 않고 인가 권한도 없는 무작위 식별자입니다. 다만 시스템 간에 레코드를 연결하는 성질이 있으므로 사용자 ID나 테넌트 이름을 인코딩하지 말고, 어떤 것의 증거로도 받아들이지 마십시오. 공개된 상관관계 키로 취급하면 로그에 남기고, 응답으로 반환하고, 공유해도 안전합니다.
traceparent 헤더는 누가 생성하나요?
헤더 없이 들어온 요청을 가장 먼저 처리하는 서비스입니다. 보통은 엣지 프록시나 API 게이트웨이, 브라우저 SDK가 그 자리에 있고, 이 서비스가 트레이스의 루트가 됩니다. 여기서 trace-id와 첫 스팬을 생성하고 샘플링까지 결정하며, 이후 홉들은 parent-id만 자기 값으로 새로 씁니다.
traceparent 헤더는 필수인가요?
아닙니다. 프로토콜 수준에서는 선택 사항이라 헤더가 없는 요청도 완전히 정상이고, 받는 서비스가 새 트레이스의 루트가 될 뿐입니다. 다만 헤더가 없으면 서비스 경계를 넘나든 작업을 하나의 트레이스로 묶을 방법이 없으니, 실무적인 의미에서만 필수입니다.
traceparent가 측정할 만한 오버헤드를 유발하나요?
유의미한 수준은 아닙니다. traceparent는 55바이트이고 tracestate가 보통 수백 바이트를 더할 뿐이라, TLS 핸드셰이크나 실제 페이로드에 비하면 무시할 만합니다. 추적의 진짜 비용은 샘플링된 스팬을 내보내고 저장하는 데서 나오지, 분산 추적 헤더를 회선에 실어 나르는 데서 나오지 않습니다.