Split View: 분산 트레이싱이 실제로 답하는 질문 — 스팬, 샘플링, 그리고 어디서 시간이 갔는가
분산 트레이싱이 실제로 답하는 질문 — 스팬, 샘플링, 그리고 어디서 시간이 갔는가
- 들어가며 — 느리다는 신고는 들어오는데 어느 서비스인지 모를 때
- 트레이스, 스팬, 컨텍스트 전파의 구조
- 로그와 메트릭으로는 답할 수 없는 질문
- 계측 — 자동 계측의 경계와 수동 스팬을 넣어야 하는 지점
- 샘플링 — 헤드 샘플링의 함정과 테일 샘플링의 값
- 스팬 속성과 비동기 경계 — 카디널리티, 큐, 링크
- 실전 — 트레이스 화면에서 실제로 무엇을 찾는가
- 마치며 — 트레이싱은 "어디"에 답하는 도구다
들어가며 — 느리다는 신고는 들어오는데 어느 서비스인지 모를 때
결제 화면이 2초 걸린다는 신고가 들어왔습니다. 대시보드를 열어 보니 게이트웨이의 p99가 1.8초로 올라가 있습니다. 그 뒤에 있는 서비스는 열 개이고, 각각의 p99를 하나씩 열어 봅니다. 전부 정상입니다.
이 상황은 흔합니다. 그리고 이 상황에서 메트릭은 원리적으로 답을 줄 수 없습니다. 각 서비스가 개별적으로 빠르다는 사실과 그것들을 순서대로 통과한 하나의 요청이 느렸다는 사실은 서로 모순이 아니기 때문입니다. 필요한 것은 서비스별 통계가 아니라 요청 하나의 전체 경로입니다.
이 글은 그 경로를 만들고 읽는 방법을 다룹니다.
트레이스, 스팬, 컨텍스트 전파의 구조
트레이스는 하나의 트레이스 ID를 공유하는 스팬들의 트리입니다. 각 스팬은 이름, 시작 시각, 종료 시각, 부모 스팬 ID, 속성, 이벤트, 상태를 가집니다. 그리고 종류(kind)를 가지는데 이것이 실무에서 생각보다 중요합니다.
- SERVER: 요청을 받아 처리하는 쪽
- CLIENT: 다른 서비스를 호출하는 쪽
- PRODUCER / CONSUMER: 메시지를 보내는 쪽과 받는 쪽
- INTERNAL: 프로세스 안의 논리적 구간
CLIENT 스팬과 SERVER 스팬의 시간 차가 네트워크와 큐잉에서 소비된 시간입니다. 두 스팬이 모두 있어야 이 값을 알 수 있습니다. 한쪽만 계측되어 있으면 "호출이 오래 걸렸다"까지만 알고 그 이유가 네트워크인지 상대 서비스인지 구분할 수 없습니다.
완성된 트레이스는 이렇게 보입니다.
Trace 4bf92f3577b34da6a3ce929d0e0e4736 총 1,847ms
├─ SERVER api-gateway POST /v1/orders 1847ms
│ ├─ CLIENT auth-svc GET /verify 38ms
│ ├─ CLIENT checkout POST /orders 1782ms
│ │ ├─ SERVER checkout POST /orders 1776ms
│ │ │ ├─ INTERNAL cart.validate 6ms
│ │ │ ├─ CLIENT inventory GET /stock 41ms
│ │ │ ├─ CLIENT pricing POST /quote 52ms
│ │ │ ├─ CLIENT db.query SELECT coupons WHERE ... 1612ms <-- 여기
│ │ │ └─ CLIENT payment POST /charge 64ms
│ │ └─ (네트워크 + 큐잉 6ms)
│ └─ INTERNAL response.serialize 9ms
컨텍스트 전파는 이 트리를 만드는 유일한 메커니즘입니다. 호출하는 쪽이 현재 트레이스 ID와 자기 스팬 ID를 헤더에 넣고, 받는 쪽이 그것을 읽어 자기 스팬의 부모로 삼습니다. 표준 헤더는 W3C traceparent입니다.
# 게이트웨이가 checkout 을 호출할 때 실제로 나가는 헤더
curl -sD - -o /dev/null http://checkout.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
# 00 버전
# 4bf92f3577b34da6a3ce929d0e0e4736 트레이스 ID — 요청 전체에서 동일
# 00f067aa0ba902b7 부모 스팬 ID — 호출 단계마다 바뀐다
# 01 샘플링 플래그 — 00 이면 이 트레이스는 저장되지 않는다
전파가 끊기는 지점은 거의 정해져 있습니다. 자동 계측이 감싸지 못하는 자체 HTTP 클라이언트, 스레드 풀이나 워커로 작업을 넘기는 코드, 메시지 큐, 그리고 서드파티 프록시가 헤더를 지우는 경우입니다. 트레이스가 이상하게 짧다면 이 네 곳부터 봅니다.
로그와 메트릭으로는 답할 수 없는 질문
트레이싱이 필요한 이유를 "세 번째 신호라서"로 설명하면 도입이 실패합니다. 정확히 어떤 질문에 답하는지를 알아야 합니다.
첫째, 하나의 요청이 어디서 시간을 썼는가. 메트릭은 집계값이라 개별 요청의 경로를 복원할 수 없습니다. 서비스 A의 p99와 서비스 B의 p99가 같은 요청의 것이라는 보장이 없습니다. 위 트레이스에서 1,612ms를 쓴 쿠폰 조회 쿼리는, 그 데이터베이스의 평균 쿼리 시간 대시보드에서는 절대 보이지 않습니다. 하루 수백만 건 중 이런 요청이 1%라면 평균은 꿈쩍도 하지 않습니다.
둘째, 반복 호출 문제. N+1 쿼리는 메트릭에 보이지 않습니다. 개별 쿼리가 4ms로 빠르기 때문입니다. 문제는 그것이 한 요청에서 340번 실행된다는 사실이고, 이것은 스팬을 세어야만 보입니다.
├─ SERVER order-api GET /v1/orders/A-99183 1421ms
│ ├─ CLIENT db.query SELECT * FROM orders WHERE id = ? 5ms
│ ├─ CLIENT db.query SELECT * FROM order_items WHERE oid = ? 4ms
│ ├─ CLIENT db.query SELECT * FROM products WHERE id = ? 4ms
│ ├─ CLIENT db.query SELECT * FROM products WHERE id = ? 4ms
│ ├─ ... (같은 쿼리 340회) ...
셋째, 조건부 경로. 특정 테넌트, 특정 기능 플래그, 특정 캐시 미스 조합에서만 느려지는 경우입니다. 로그로는 각 서비스의 단면만 보이고, 그 단면들을 시간으로 짝지어 이어 붙이는 것은 추측입니다. 트레이스는 그 조합을 하나의 객체로 보여주고, 속성으로 필터링할 수 있게 해줍니다.
반대로 트레이싱이 답하지 못하는 것도 분명합니다. 언제부터 나빠졌는지, 얼마나 많은 사용자가 영향을 받는지는 메트릭의 질문입니다. 그 스팬 안에서 어떤 값을 받아 어떤 분기를 탔는지는 로그의 질문입니다. 트레이스는 "어디"에 답하고, 메트릭은 "언제와 얼마나", 로그는 "왜"에 답합니다.
계측 — 자동 계측의 경계와 수동 스팬을 넣어야 하는 지점
시작은 자동 계측입니다. 코드를 고치지 않고 I/O 경계에 스팬이 생깁니다.
npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
OTEL_SERVICE_NAME=checkout-api \
OTEL_RESOURCE_ATTRIBUTES=service.version=1.42.3,deployment.environment=prod \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability:4318 \
OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=1.0 \
node --require @opentelemetry/auto-instrumentations-node/register server.js
parentbased_traceidratio는 부모 스팬의 결정을 그대로 따르고, 부모가 없을 때만 비율로 판단합니다. 이것이 기본값이어야 합니다. 서비스마다 독립적으로 확률 판정을 하면 트레이스가 중간에 잘려 나갑니다.
자동 계측이 주는 것은 정확히 하나입니다. 네트워크 경계. HTTP 서버와 클라이언트, gRPC, 데이터베이스 드라이버, 레디스, 메시지 큐 클라이언트가 대상입니다.
자동 계측이 보지 못하는 것도 하나입니다. 프로세스 안에서 일어나는 모든 일. 인메모리 정렬, 템플릿 렌더링, 암호화, 직렬화, 락 대기, 이벤트 루프 지연은 어떤 스팬으로도 나타나지 않습니다. 이것들은 부모 스팬의 self time, 즉 자식 스팬들이 차지하지 않은 시간으로만 드러납니다.
├─ SERVER report-api GET /v1/reports/monthly 3204ms
│ ├─ CLIENT db.query SELECT ... FROM ledger 412ms
│ └─ CLIENT s3.upload PUT report-2026-07.pdf 260ms
│ self time = 3204 - 412 - 260 = 2532ms <-- 계측되지 않은 구간
self time이 큰 스팬을 발견하면 그 안에 수동 스팬을 넣습니다. 넣어야 하는 지점은 다섯 곳입니다.
- 루프와 배치 경계 — 반복 횟수를 속성으로 남깁니다.
- 캐시 조회와 미스 경로 — 히트 여부를 속성으로 남기면 캐시 성능이 트레이스에서 바로 보입니다.
- 자동 계측이 없는 서드파티 SDK 호출
- 락 대기, 큐 대기, 커넥션 풀 대기
- 직렬화, 압축, 이미지 처리 같은 CPU 구간
import { trace, SpanStatusCode } from '@opentelemetry/api'
const tracer = trace.getTracer('checkout', '1.42.3')
export async function applyPromotions(cart, tenantId) {
return tracer.startActiveSpan('checkout.applyPromotions', async (span) => {
span.setAttribute('cart.item_count', cart.items.length)
span.setAttribute('promotion.engine', 'rules-v3')
span.setAttribute('tenant.id', tenantId)
try {
const cached = await rules.fromCache(tenantId)
span.setAttribute('promotion.cache_hit', Boolean(cached))
const result = await (cached ?? rules.compile(tenantId)).evaluate(cart)
span.setAttribute('promotion.rules_evaluated', result.evaluated)
span.setAttribute('promotion.matched_count', result.matched.length)
return result
} catch (err) {
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
throw err
} finally {
span.end()
}
})
}
스팬 이름은 저카디널리티여야 합니다. GET /v1/orders/A-99183이 아니라 GET /v1/orders/:id입니다. 백엔드가 스팬 이름으로 그룹핑하기 때문에, 이름에 ID가 들어가면 집계 뷰가 전부 무너집니다. 구체적인 값은 속성으로 보냅니다.
샘플링 — 헤드 샘플링의 함정과 테일 샘플링의 값
전량 저장은 대부분의 조직에서 비용이 맞지 않습니다. 문제는 무엇을 버리느냐입니다.
헤드 샘플링은 루트 스팬을 만드는 순간, 즉 아직 아무 일도 일어나지 않았을 때 결정합니다. 결과를 모르므로 무작위로 버릴 수밖에 없습니다. 그리고 무작위로 버린다는 것은, 드문 사건을 그 드문 만큼 정확히 버린다는 뜻입니다.
python3 - <<'PY'
rate = 0.01 # 헤드 샘플링 1%
errors_per_hour = 5 # 조사하려는 오류의 실제 발생 빈도
kept = errors_per_hour * rate
print(f"보존되는 오류 트레이스: 시간당 {kept:.2f}건")
print(f"1건을 보려면 평균 {1/kept:.0f}시간 대기")
PY
# 보존되는 오류 트레이스: 시간당 0.05건
# 1건을 보려면 평균 20시간 대기
이것이 헤드 샘플링의 실질적 결말입니다. 조사가 필요할 때 그 트레이스는 없습니다. 그리고 있는 트레이스는 전부 정상 요청이라 볼 이유가 없습니다.
테일 샘플링은 트레이스가 완료될 때까지 스팬을 버퍼에 모아 두었다가, 결과를 보고 결정합니다. 오류가 있으면 남기고, 느리면 남기고, 나머지는 소량만 남깁니다.
# otel-collector-tailsampler.yaml
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100000
expected_new_traces_per_sec: 2000
policies:
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: keep-slow
type: latency
latency:
threshold_ms: 800
- name: keep-vip-tenants
type: string_attribute
string_attribute:
key: tenant.tier
values: [enterprise]
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 2
테일 샘플링이 무료가 아니라는 점은 반드시 짚고 넘어가야 합니다. 세 가지 비용이 있습니다.
첫째, 에이전트에서 컬렉터까지의 전송량은 줄지 않습니다. 모든 스팬이 컬렉터에 도착해야 판정할 수 있기 때문입니다. 절약되는 것은 백엔드 저장과 인덱싱 비용뿐입니다. 컬렉터 자체의 CPU와 네트워크는 오히려 늘어납니다.
둘째, 메모리입니다. decision_wait 동안 모든 스팬을 들고 있어야 합니다.
python3 - <<'PY'
traces_per_sec, wait_sec, spans, span_kb = 2000, 10, 12, 1.2
mb = traces_per_sec * wait_sec * spans * span_kb / 1024
print(f"버퍼 메모리 대략 {mb:.0f} MB (여유 2배 잡으면 {mb*2:.0f} MB)")
PY
# 버퍼 메모리 대략 281 MB (여유 2배 잡으면 563 MB)
셋째, 그리고 가장 많이 실수하는 부분입니다. 같은 트레이스의 모든 스팬이 같은 컬렉터 인스턴스에 도착해야 합니다. 컬렉터를 여러 대로 스케일아웃하고 일반 로드밸런서를 앞에 두면, 한 트레이스의 스팬이 여러 인스턴스로 흩어져 각자 조각난 트레이스를 보고 판정합니다. 결과는 무작위로 잘린 트레이스입니다. 해법은 trace ID 기반 라우팅을 하는 게이트웨이 계층을 두는 것입니다.
# otel-collector-gateway.yaml — 앞단에서 trace ID 로 라우팅한다
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: otel-tailsampler.observability.svc.cluster.local
port: 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [loadbalancing]
| 항목 | 헤드 샘플링 | 테일 샘플링 |
|---|---|---|
| 결정 시점 | 루트 스팬 생성 시 | 트레이스 완료 후 decision_wait 경과 시 |
| 느린 요청 보존 | 확률적으로만 | 규칙으로 100% |
| 오류 트레이스 보존 | 확률적으로만 | 규칙으로 100% |
| 에이전트와 네트워크 비용 | 샘플링 비율만큼 감소 | 감소 없음 |
| 백엔드 저장 비용 | 감소 | 감소 |
| 컬렉터 메모리 | 무시 가능 | 초당 트레이스 수 곱하기 대기 시간만큼 버퍼 |
| 운영 요구 사항 | 없음 | trace ID 기반 라우팅 필수 |
| 설정 위치 | SDK 환경변수 | 컬렉터 프로세서 |
실무에서는 두 가지를 조합합니다. 트래픽이 아주 많은 서비스는 헤드 샘플링으로 10~50% 수준까지 미리 줄이고, 그 위에 테일 샘플링을 얹어 오류와 느린 요청을 건집니다. 헤드에서 이미 버린 것은 테일에서 되살릴 수 없으므로, 헤드 비율은 감당 가능한 한 높게 잡는 것이 원칙입니다.
스팬 속성과 비동기 경계 — 카디널리티, 큐, 링크
"트레이스에도 카디널리티를 조심하라"는 조언은 절반만 맞습니다. 스팬 속성의 고카디널리티는 메트릭에서와 달리 폭발 문제가 아닙니다. 주문 ID, 사용자 ID, 쿼리 파라미터는 트레이스에 넣으라고 있는 값입니다. 그것이 없으면 트레이스는 필터링할 수 없는 그림이 됩니다.
문제가 생기는 곳은 두 군데입니다.
첫째, 스팬을 메트릭으로 변환할 때입니다. spanmetrics 커넥터로 스팬에서 RED 메트릭을 만들면, 차원으로 지정한 속성이 그대로 메트릭 레이블이 됩니다. 여기에 사용자 ID를 넣으면 시계열이 폭발합니다.
connectors:
spanmetrics:
# 여기에 나열한 것만 메트릭 레이블이 된다 — 저카디널리티만 넣는다
dimensions:
- name: http.route
- name: http.request.method
- name: deployment.environment
histogram:
explicit:
buckets: [10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s]
둘째, 스팬 자체의 크기입니다. OpenTelemetry SDK는 기본적으로 스팬당 속성 개수에 상한(기본 128)을 두지만 속성 값 길이에는 기본 상한이 없습니다. 요청 본문 전체를 속성에 넣으면 스팬 하나가 수백 KB가 되고, 전송과 저장 비용이 그대로 따라옵니다. 필요하면 명시적으로 제한합니다.
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64 \
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048 \
node --require @opentelemetry/auto-instrumentations-node/register server.js
비동기 경계는 더 까다롭습니다. 메시지 큐에서는 프로듀서 스팬이 이미 끝난 뒤에 컨슈머가 동작합니다. 부모-자식 관계를 그대로 쓰면 부모가 이미 종료된 상태라 트레이스의 시간 축이 이상해지고, 배치 컨슈머는 아예 표현이 불가능합니다. 메시지 100개를 한 번에 처리하면 부모가 100개인데, 스팬은 부모를 하나만 가질 수 있습니다.
해법은 링크입니다.
import { propagation, context, trace, SpanKind } from '@opentelemetry/api'
// 프로듀서: 메시지 헤더에 컨텍스트를 주입한다
export async function publish(order) {
return tracer.startActiveSpan('orders.publish', { kind: SpanKind.PRODUCER }, async (span) => {
const headers = {}
propagation.inject(context.active(), headers)
await producer.send({ topic: 'orders', messages: [{ value: JSON.stringify(order), headers }] })
span.end()
})
}
// 배치 컨슈머: 각 메시지의 컨텍스트를 부모가 아니라 링크로 붙인다
export async function consumeBatch(messages) {
const links = messages
.map((m) => trace.getSpan(propagation.extract(context.active(), m.headers))?.spanContext())
.filter(Boolean)
.map((spanContext) => ({ context: spanContext }))
return tracer.startActiveSpan(
'orders.processBatch',
{ kind: SpanKind.CONSUMER, links },
async (span) => {
span.setAttribute('messaging.batch.message_count', messages.length)
await Promise.all(messages.map(handle))
span.end()
}
)
}
메시지가 하나뿐인 컨슈머라면 부모-자식으로 이어도 됩니다. 다만 큐 대기 시간이 길면 트레이스 하나의 지속 시간이 몇 시간짜리가 되어 백엔드에서 다루기 어려워집니다. 대기 시간이 긴 파이프라인은 링크로 끊고, 대신 큐 대기 시간을 속성으로 남기는 편이 실용적입니다.
실전 — 트레이스 화면에서 실제로 무엇을 찾는가
도구를 켜 놓고도 쓰지 않는 가장 큰 이유는 조사 순서가 없어서입니다. 다음 순서가 대부분의 경우에 통합니다.
- 메트릭에서 시작합니다. 어느 라우트의 어느 시간대가 나빠졌는지 확정합니다. 트레이스를 무작정 뒤지는 것으로 시작하면 시간만 씁니다.
- 서비스 그래프에서 지연이나 오류율이 올라간 엣지를 찾습니다. 호출 관계 자체가 바뀌었는지도 여기서 보입니다.
- 그 라우트, 그 시간대, 지속 시간이 상위인 트레이스 목록을 봅니다. 대부분의 백엔드가 지속 시간과 속성으로 필터링하는 쿼리를 제공합니다.
- 트레이스 하나를 열고 self time이 가장 큰 스팬을 찾습니다. 가장 긴 스팬이 아니라 self time이 가장 큰 스팬입니다. 가장 긴 스팬은 보통 루트이고 정보가 없습니다.
- 그 스팬의 속성을 봅니다. 캐시 미스인지, 특정 테넌트인지, 재시도 횟수가 몇인지.
- 같은 trace ID로 로그를 조회합니다. 여기서 "왜"가 나옵니다.
여기서 한 가지 규율이 필요합니다. 느린 트레이스 하나를 보고 결론 내리지 않습니다. 한 건은 우연일 수 있습니다. 같은 조건의 트레이스를 여러 개 열어 공통 패턴을 확인하고, 정상 트레이스와 나란히 비교합니다. 좋은 백엔드는 이를 위해 스팬별 지속 시간 분포나 트레이스 비교 뷰를 제공합니다.
OpenTelemetry가 표준화한 것과 아직 아닌 것
도입 판단에 영향을 주므로 경계를 알고 있어야 합니다.
표준화되어 안심하고 기대도 되는 부분은 API와 SDK의 구조, OTLP 전송 프로토콜, W3C 기반 컨텍스트 전파, 그리고 컬렉터입니다. 계측 코드를 OpenTelemetry API로 작성해 두면 백엔드를 바꿔도 코드를 고칠 일이 거의 없습니다. HTTP 같은 핵심 영역의 시맨틱 규약도 안정화되었습니다.
반면 여전히 움직이는 부분도 있습니다. 여러 도메인의 시맨틱 규약이 아직 실험 단계이거나 이름이 바뀌는 중이고, 언어별 SDK의 성숙도 차이도 큽니다. 스팬에서 메트릭을 만드는 방식이나 서비스 그래프 생성 로직은 백엔드마다 다르고, 트레이스를 질의하는 언어도 표준이 없습니다. 이 영역에 의존하는 대시보드와 알림은 백엔드에 묶인다고 보고 설계해야 합니다.
마치며 — 트레이싱은 "어디"에 답하는 도구다
기억할 것은 한 문장입니다. 트레이싱은 요청 하나가 어디서 시간을 썼는지에 답하는 도구이고, 그 답은 컨텍스트가 끊기지 않고 그 트레이스가 버려지지 않았을 때만 존재합니다.
그래서 도입 순서도 정해집니다. 먼저 전파를 확인합니다. 트레이스가 서비스 경계에서 끊기고 있다면 다른 모든 투자가 무의미합니다. 그다음 샘플링을 봅니다. 헤드 샘플링만 쓰고 있다면 조사가 필요한 순간에 트레이스가 없을 것이므로, 테일 샘플링과 trace ID 라우팅을 함께 도입합니다. 마지막으로 self time이 큰 구간에 수동 스팬을 넣어 자동 계측의 빈칸을 메웁니다.
가장 먼저 할 일은 지금 프로덕션에서 트레이스 하나를 열어 보는 것입니다. 서비스가 열 개인데 스팬이 세 개만 보인다면, 그것이 다음 주에 해야 할 일 전부입니다.
더 파고들 자료입니다.
The Questions Distributed Tracing Actually Answers — Spans, Sampling, and Where the Time Went
- Introduction — When the Reports Say Slow and You Do Not Know Which Service
- The Structure of Traces, Spans, and Context Propagation
- The Questions Logs and Metrics Cannot Answer
- Instrumentation — The Boundary of Auto-Instrumentation and Where Manual Spans Belong
- Sampling — The Trap of Head Sampling and the Value of Tail Sampling
- Span Attributes and Async Boundaries — Cardinality, Queues, Links
- In Practice — What You Are Actually Looking For on a Trace Screen
- Closing — Tracing Is the Tool That Answers "Where"
Introduction — When the Reports Say Slow and You Do Not Know Which Service
A report comes in that the checkout screen takes 2 seconds. You open the dashboard and the gateway p99 has risen to 1.8 seconds. There are ten services behind it, so you open each of their p99 graphs one by one. All normal.
This situation is common. And in this situation, metrics cannot give you an answer in principle. The fact that each service is individually fast and the fact that one request passing through them in sequence was slow are not contradictory. What you need is not per-service statistics but the full path of a single request.
This post is about building and reading that path.
The Structure of Traces, Spans, and Context Propagation
A trace is a tree of spans sharing one trace ID. Each span has a name, a start time, an end time, a parent span ID, attributes, events, and a status. It also has a kind, and that matters more in practice than people expect.
- SERVER: the side receiving and handling a request
- CLIENT: the side calling another service
- PRODUCER / CONSUMER: the side sending a message and the side receiving it
- INTERNAL: a logical segment inside a process
The time difference between a CLIENT span and a SERVER span is the time consumed in the network and in queueing. You need both spans to know that value. If only one side is instrumented, you know only that "the call took a long time" and cannot tell whether the reason was the network or the other service.
A completed trace looks like this.
Trace 4bf92f3577b34da6a3ce929d0e0e4736 total 1,847ms
├─ SERVER api-gateway POST /v1/orders 1847ms
│ ├─ CLIENT auth-svc GET /verify 38ms
│ ├─ CLIENT checkout POST /orders 1782ms
│ │ ├─ SERVER checkout POST /orders 1776ms
│ │ │ ├─ INTERNAL cart.validate 6ms
│ │ │ ├─ CLIENT inventory GET /stock 41ms
│ │ │ ├─ CLIENT pricing POST /quote 52ms
│ │ │ ├─ CLIENT db.query SELECT coupons WHERE ... 1612ms <-- here
│ │ │ └─ CLIENT payment POST /charge 64ms
│ │ └─ (network + queueing 6ms)
│ └─ INTERNAL response.serialize 9ms
Context propagation is the only mechanism that builds this tree. The caller puts the current trace ID and its own span ID into a header, and the receiver reads them and adopts them as the parent of its own span. The standard header is the W3C traceparent.
# The header that actually goes out when the gateway calls checkout
curl -sD - -o /dev/null http://checkout.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
# 00 version
# 4bf92f3577b34da6a3ce929d0e0e4736 trace ID — identical across the whole request
# 00f067aa0ba902b7 parent span ID — changes at each call hop
# 01 sampling flag — if 00, this trace will not be stored
The places where propagation breaks are almost always the same. A hand-rolled HTTP client that auto-instrumentation cannot wrap, code that hands work off to a thread pool or a worker, message queues, and third-party proxies that strip headers. If a trace looks suspiciously short, start with those four.
The Questions Logs and Metrics Cannot Answer
Explaining why you need tracing as "because it is the third signal" is how adoption fails. You have to know exactly which question it answers.
First, where did one request spend its time. Metrics are aggregates, so they cannot reconstruct the path of an individual request. There is no guarantee that the p99 of service A and the p99 of service B belong to the same request. The coupon lookup query that burned 1,612ms in the trace above will never show up on that database's average query time dashboard. If requests like that are 1 percent of several million a day, the average does not budge.
Second, the repeated call problem. N+1 queries are invisible in metrics, because each individual query is a fast 4ms. The problem is that it runs 340 times within one request, and that only becomes visible when you count spans.
├─ SERVER order-api GET /v1/orders/A-99183 1421ms
│ ├─ CLIENT db.query SELECT * FROM orders WHERE id = ? 5ms
│ ├─ CLIENT db.query SELECT * FROM order_items WHERE oid = ? 4ms
│ ├─ CLIENT db.query SELECT * FROM products WHERE id = ? 4ms
│ ├─ CLIENT db.query SELECT * FROM products WHERE id = ? 4ms
│ ├─ ... (the same query 340 times) ...
Third, conditional paths. Cases that only get slow for a particular tenant, a particular feature flag, or a particular combination of cache misses. Logs show you only a cross-section of each service, and stitching those cross-sections together by time is guesswork. A trace shows you that combination as a single object and lets you filter on attributes.
Conversely, what tracing cannot answer is equally clear. When it started getting worse and how many users are affected are questions for metrics. Which values arrived inside that span and which branch they took is a question for logs. Traces answer "where", metrics answer "when and how much", and logs answer "why".
Instrumentation — The Boundary of Auto-Instrumentation and Where Manual Spans Belong
Start with auto-instrumentation. Spans appear at I/O boundaries without touching the code.
npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
OTEL_SERVICE_NAME=checkout-api \
OTEL_RESOURCE_ATTRIBUTES=service.version=1.42.3,deployment.environment=prod \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability:4318 \
OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=1.0 \
node --require @opentelemetry/auto-instrumentations-node/register server.js
parentbased_traceidratio follows the parent span's decision as-is and only makes a probabilistic judgement when there is no parent. This should be your default. If each service makes an independent probabilistic decision, traces get chopped off in the middle.
What auto-instrumentation gives you is exactly one thing. Network boundaries. HTTP servers and clients, gRPC, database drivers, Redis, and message queue clients are the targets.
What auto-instrumentation does not see is also one thing. Everything that happens inside the process. In-memory sorting, template rendering, encryption, serialization, lock waits, and event loop delay never appear as any span. They surface only as the self time of the parent span, that is, the time not accounted for by child spans.
├─ SERVER report-api GET /v1/reports/monthly 3204ms
│ ├─ CLIENT db.query SELECT ... FROM ledger 412ms
│ └─ CLIENT s3.upload PUT report-2026-07.pdf 260ms
│ self time = 3204 - 412 - 260 = 2532ms <-- uninstrumented segment
When you find a span with large self time, put manual spans inside it. There are five places where you should.
- Loop and batch boundaries — record the iteration count as an attribute.
- Cache lookups and miss paths — record hit or miss as an attribute and cache performance becomes visible directly in the trace.
- Third-party SDK calls with no auto-instrumentation
- Lock waits, queue waits, connection pool waits
- CPU segments such as serialization, compression, and image processing
import { trace, SpanStatusCode } from '@opentelemetry/api'
const tracer = trace.getTracer('checkout', '1.42.3')
export async function applyPromotions(cart, tenantId) {
return tracer.startActiveSpan('checkout.applyPromotions', async (span) => {
span.setAttribute('cart.item_count', cart.items.length)
span.setAttribute('promotion.engine', 'rules-v3')
span.setAttribute('tenant.id', tenantId)
try {
const cached = await rules.fromCache(tenantId)
span.setAttribute('promotion.cache_hit', Boolean(cached))
const result = await (cached ?? rules.compile(tenantId)).evaluate(cart)
span.setAttribute('promotion.rules_evaluated', result.evaluated)
span.setAttribute('promotion.matched_count', result.matched.length)
return result
} catch (err) {
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
throw err
} finally {
span.end()
}
})
}
Span names must be low cardinality. Not GET /v1/orders/A-99183 but GET /v1/orders/:id. Backends group by span name, so putting an ID in the name collapses every aggregate view. Send the specific values as attributes.
Sampling — The Trap of Head Sampling and the Value of Tail Sampling
Storing everything does not add up financially for most organizations. The question is what you throw away.
Head sampling decides at the moment the root span is created, that is, when nothing has happened yet. Since it does not know the outcome, it has no choice but to discard at random. And discarding at random means discarding rare events exactly in proportion to their rarity.
python3 - <<'PY'
rate = 0.01 # head sampling at 1%
errors_per_hour = 5 # the real frequency of the error you want to investigate
kept = errors_per_hour * rate
print(f"error traces retained: {kept:.2f} per hour")
print(f"average wait to see one: {1/kept:.0f} hours")
PY
# error traces retained: 0.05 per hour
# average wait to see one: 20 hours
That is the practical ending of head sampling. When you need to investigate, the trace is not there. And the traces that are there are all successful requests, so there is no reason to look at them.
Tail sampling buffers spans until the trace completes and then decides based on the outcome. Keep it if there was an error, keep it if it was slow, keep only a small share of the rest.
# otel-collector-tailsampler.yaml
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100000
expected_new_traces_per_sec: 2000
policies:
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: keep-slow
type: latency
latency:
threshold_ms: 800
- name: keep-vip-tenants
type: string_attribute
string_attribute:
key: tenant.tier
values: [enterprise]
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 2
That tail sampling is not free is something that has to be said out loud. There are three costs.
First, the volume from agent to collector does not go down. Every span has to reach the collector before a decision can be made. What you save is only backend storage and indexing cost. The collector's own CPU and network actually increase.
Second, memory. You have to hold every span for the duration of decision_wait.
python3 - <<'PY'
traces_per_sec, wait_sec, spans, span_kb = 2000, 10, 12, 1.2
mb = traces_per_sec * wait_sec * spans * span_kb / 1024
print(f"buffer memory roughly {mb:.0f} MB (allow 2x headroom: {mb*2:.0f} MB)")
PY
# buffer memory roughly 281 MB (allow 2x headroom: 563 MB)
Third, and this is where people make the most mistakes. Every span of the same trace must arrive at the same collector instance. Scale the collector out across several nodes with an ordinary load balancer in front and one trace's spans scatter across instances, each judging a fragmented trace on its own. The result is randomly truncated traces. The solution is a gateway layer that routes by trace ID.
# otel-collector-gateway.yaml — route by trace ID at the front
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: otel-tailsampler.observability.svc.cluster.local
port: 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [loadbalancing]
| Item | Head sampling | Tail sampling |
|---|---|---|
| Decision point | At root span creation | After the trace completes and decision_wait elapses |
| Retention of slow requests | Probabilistic only | 100% by rule |
| Retention of error traces | Probabilistic only | 100% by rule |
| Agent and network cost | Reduced by the sampling ratio | No reduction |
| Backend storage cost | Reduced | Reduced |
| Collector memory | Negligible | Buffer proportional to traces per second times wait time |
| Operational requirement | None | Trace ID based routing is mandatory |
| Configuration location | SDK environment variables | Collector processor |
In practice you combine the two. Services with very high traffic get cut down to somewhere between 10 and 50 percent with head sampling first, and tail sampling is layered on top to fish out errors and slow requests. Anything already discarded at the head cannot be revived at the tail, so as a principle you set the head ratio as high as you can afford.
Span Attributes and Async Boundaries — Cardinality, Queues, Links
The advice "watch out for cardinality in traces too" is only half right. High cardinality in span attributes is not an explosion problem the way it is in metrics. Order IDs, user IDs, and query parameters are exactly the values traces exist to hold. Without them a trace is a picture you cannot filter.
Problems arise in two places.
First, when you convert spans into metrics. Build RED metrics from spans with the spanmetrics connector and the attributes you nominate as dimensions become metric labels directly. Put a user ID in there and the time series explode.
connectors:
spanmetrics:
# only what is listed here becomes a metric label — put low cardinality only
dimensions:
- name: http.route
- name: http.request.method
- name: deployment.environment
histogram:
explicit:
buckets: [10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s]
Second, the size of the span itself. The OpenTelemetry SDK caps the attribute count per span by default (128), but there is no default cap on attribute value length. Put a whole request body in an attribute and one span becomes hundreds of KB, with transport and storage cost following right along. Set explicit limits if you need to.
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64 \
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048 \
node --require @opentelemetry/auto-instrumentations-node/register server.js
Async boundaries are trickier. In a message queue, the consumer runs after the producer span has already ended. Use a parent-child relationship as-is and the parent is already closed, which makes the trace's time axis strange, and a batch consumer becomes impossible to represent at all. Process 100 messages at once and there are 100 parents, but a span can only have one parent.
The solution is links.
import { propagation, context, trace, SpanKind } from '@opentelemetry/api'
// Producer: inject the context into the message headers
export async function publish(order) {
return tracer.startActiveSpan('orders.publish', { kind: SpanKind.PRODUCER }, async (span) => {
const headers = {}
propagation.inject(context.active(), headers)
await producer.send({ topic: 'orders', messages: [{ value: JSON.stringify(order), headers }] })
span.end()
})
}
// Batch consumer: attach each message context as a link rather than a parent
export async function consumeBatch(messages) {
const links = messages
.map((m) => trace.getSpan(propagation.extract(context.active(), m.headers))?.spanContext())
.filter(Boolean)
.map((spanContext) => ({ context: spanContext }))
return tracer.startActiveSpan(
'orders.processBatch',
{ kind: SpanKind.CONSUMER, links },
async (span) => {
span.setAttribute('messaging.batch.message_count', messages.length)
await Promise.all(messages.map(handle))
span.end()
}
)
}
For a consumer handling only one message, parent-child is acceptable. Note, though, that if queue wait times are long, one trace ends up lasting hours, which backends handle badly. For pipelines with long waits it is more practical to cut with links and record the queue wait time as an attribute instead.
In Practice — What You Are Actually Looking For on a Trace Screen
The biggest reason people leave the tool switched on and never use it is that there is no investigation order. The following order works in most cases.
- Start from metrics. Pin down which route and which time range got worse. Starting by rummaging through traces at random only burns time.
- Find the edge in the service graph where latency or error rate rose. Whether the call relationships themselves changed is also visible here.
- Look at the list of traces for that route and time range with the highest duration. Most backends provide a query that filters by duration and attributes.
- Open one trace and find the span with the largest self time. Not the longest span, the largest self time. The longest span is usually the root and carries no information.
- Look at that span's attributes. Cache miss, particular tenant, how many retries.
- Query the logs by the same trace ID. This is where "why" comes from.
One discipline is required here. Do not draw conclusions from one slow trace. A single case may be coincidence. Open several traces under the same conditions to confirm a common pattern, and compare them side by side with a normal trace. Good backends provide per-span duration distributions or trace comparison views for exactly this.
What OpenTelemetry Has Standardized and What It Has Not
This affects adoption decisions, so you should know where the boundary is.
The parts that are standardized and safe to lean on are the structure of the API and SDK, the OTLP transport protocol, W3C-based context propagation, and the collector. Write your instrumentation code against the OpenTelemetry API and switching backends will rarely require touching it. Semantic conventions for core areas such as HTTP have also stabilized.
Parts that are still moving remain, however. Semantic conventions in several domains are still experimental or in the middle of being renamed, and maturity varies widely across language SDKs. How metrics are derived from spans and how service graphs are generated differ per backend, and there is no standard language for querying traces. Design on the assumption that dashboards and alerts depending on that area are tied to the backend.
Closing — Tracing Is the Tool That Answers "Where"
There is one sentence to remember. Tracing is the tool that answers where a single request spent its time, and that answer exists only when the context was not broken and the trace was not thrown away.
That fixes the adoption order too. First check propagation. If traces are breaking at service boundaries, every other investment is meaningless. Then look at sampling. If you are running head sampling only, the trace will not be there at the moment you need to investigate, so introduce tail sampling together with trace ID routing. Finally, put manual spans into the segments with large self time to fill in the blanks auto-instrumentation leaves.
The very first thing to do is open one trace in production right now. If you have ten services and see only three spans, that is your entire agenda for next week.
Further reading.