Split View: 애플리케이션에 OpenTelemetry 붙이기 — 자동 계측에서 수동 스팬까지, 그리고 컬렉터를 두는 이유
애플리케이션에 OpenTelemetry 붙이기 — 자동 계측에서 수동 스팬까지, 그리고 컬렉터를 두는 이유
- 들어가며 — 대시보드는 있는데 왜 느린지는 모를 때
- 순서를 먼저 정한다
- 1단계 — 자동 계측만으로 어디까지 가는가
- 2단계 — 리소스 속성은 나중에 못 고친다
- 3단계 — 컨텍스트 전파가 끊기는 네 지점
- 4단계 — 수동 스팬은 self time이 큰 곳에만
- 5단계 — 컬렉터를 앱과 백엔드 사이에 두는 이유
- 계측이 앱을 망가뜨리는 방식들
- 마치며 — 계측의 가치는 스팬 수가 아니라 트레이스의 완결성에서 나온다
들어가며 — 대시보드는 있는데 왜 느린지는 모를 때
주문 API의 p99가 1.4초입니다. Grafana에는 CPU, 메모리, 요청 수, 에러율 패널이 이미 있고 전부 초록색입니다. 데이터베이스 대시보드도 정상입니다. 그런데 사용자는 느리다고 하고, 우리는 어느 코드가 그 1.4초를 썼는지 모릅니다.
이 상태에서 필요한 것은 패널 하나 더가 아니라 계측입니다. 계측은 "무슨 일이 언제 얼마나 걸렸는지"를 코드가 스스로 말하게 만드는 작업이고, OpenTelemetry는 그 말하기의 형식과 전송 규약을 표준화한 프로젝트입니다.
이 글은 서비스 하나를 처음부터 끝까지 계측합니다. Python FastAPI 서비스를 예로 쓰지만 순서는 언어와 무관합니다. 검증 기준은 2026년 7월 기준 OpenTelemetry Collector v0.157.0, Semantic Conventions v1.43.0, Python SDK 1.3x대입니다. 시맨틱 규약은 이름이 계속 바뀌는 영역이 남아 있으므로 속성 이름은 항상 해당 버전의 Semantic Conventions 레지스트리에서 확인하는 편이 안전합니다.
순서를 먼저 정한다
계측을 실패하는 팀은 거의 같은 방식으로 실패합니다. 코드에 수동 스팬부터 심기 시작해서, 2주 뒤에 스팬은 300개인데 트레이스는 여전히 서비스 경계에서 끊겨 있는 상태가 됩니다.
작동하는 순서는 아래와 같습니다.
| 단계 | 하는 일 | 걸리는 시간 | 이 단계를 건너뛰면 |
|---|---|---|---|
| 1 | 자동 계측을 켜고 트레이스가 백엔드에 도착하는지 확인 | 반나절 | 이후 모든 디버깅이 추측이 됩니다 |
| 2 | 리소스 속성 확정 (service.name 등) | 반나절 | 나중에 바꾸면 과거 데이터와 끊깁니다 |
| 3 | 서비스 경계를 넘는 전파가 살아 있는지 검증 | 1일 | 스팬을 아무리 늘려도 트레이스가 조각납니다 |
| 4 | self time이 큰 구간에만 수동 스팬 추가 | 계속 | 자동 계측의 빈칸이 영원히 남습니다 |
| 5 | 컬렉터를 앞에 두고 가공과 샘플링을 이관 | 1일 | 정책을 바꿀 때마다 전 서비스를 재배포합니다 |
핵심은 3번이 4번보다 앞이라는 점입니다. 전파가 끊긴 상태에서 수동 스팬을 추가하는 것은 조각난 트레이스를 더 잘게 조각내는 일입니다.
1단계 — 자동 계측만으로 어디까지 가는가
Python은 opentelemetry-instrument 런처가 프로세스 시작 시점에 설치된 계측 패키지를 훅으로 붙입니다. 코드 변경은 없습니다.
pip install \
'opentelemetry-distro[otlp]' \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-requests \
opentelemetry-instrumentation-redis \
opentelemetry-instrumentation-logging
# 설치된 계측 패키지를 자동으로 탐지해서 붙여 준다
opentelemetry-bootstrap --action=install
실행은 환경변수만으로 제어합니다. 이것이 자동 계측의 핵심 장점입니다. 계측 설정이 코드가 아니라 배포 매니페스트에 있으므로, 엔드포인트나 샘플링 비율을 바꾸는 데 코드 리뷰가 필요 없습니다.
export OTEL_SERVICE_NAME=checkout-api
export OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod,service.namespace=commerce
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_SAMPLER=parentbased_always_on
export OTEL_PYTHON_LOG_CORRELATION=true
opentelemetry-instrument uvicorn app.main:app --host 0.0.0.0 --port 8000
parentbased_always_on으로 시작하는 것을 권합니다. 처음부터 비율 샘플링을 켜면 트레이스가 안 보일 때 그것이 계측 문제인지 샘플링 때문인지 구분할 수 없습니다. 샘플링은 데이터가 흐르는 것을 확인한 뒤 컬렉터에서 붙입니다.
이 상태에서 얻는 것은 정확히 하나, 네트워크 경계입니다.
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ CLIENT redis GET promo:rules:t-8871 2ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (나머지 1308ms는 어떤 스팬에도 속하지 않음)
마지막 줄이 전부입니다. 자동 계측은 어디가 문제가 아닌지를 1308ms의 공백으로 알려 줍니다. 그 공백을 self time이라고 부르고, 4단계에서 여기에만 수동 스팬을 넣습니다.
자동 계측이 절대 보지 못하는 것
- 프로세스 안의 CPU 작업 — 직렬화, 압축, 템플릿 렌더링, 암호화, 이미지 처리
- 락 대기와 커넥션 풀 대기 — 대기 시간이 커넥션을 얻은 뒤의 쿼리 스팬에 포함되지 않습니다
- GIL 경합과 이벤트 루프 지연
- 계측 패키지가 없는 서드파티 SDK 호출
- 비즈니스 로직의 분기 — 어떤 규칙이 몇 개 평가됐는지
2단계 — 리소스 속성은 나중에 못 고친다
리소스는 "이 텔레메트리를 만든 주체가 무엇인가"를 설명하는 속성 집합입니다. 스팬 속성과 달리 리소스 속성은 그 프로세스가 내보내는 모든 신호에 붙습니다. 그리고 한 번 정하면 바꾸기가 어렵습니다. service.name을 바꾸는 순간 대시보드, 알림, 서비스 그래프, 과거 데이터와의 연결이 전부 끊깁니다.
# 최소 집합 — 이 셋이 없으면 데이터가 어디서 왔는지 알 수 없다
OTEL_SERVICE_NAME=checkout-api
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod
# 실무에서 추가로 유용한 것들
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,\
deployment.environment.name=prod,\
service.namespace=commerce,\
service.instance.id=checkout-api-7d9f4b-x2k9m
Kubernetes에서는 인스턴스 식별자를 하드코딩하지 말고 Downward API로 주입합니다.
# deployment.yaml
env:
- name: OTEL_SERVICE_NAME
value: checkout-api
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OTEL_RESOURCE_ATTRIBUTES
value: >-
service.version=2.7.1,
deployment.environment.name=prod,
service.namespace=commerce,
service.instance.id=$(POD_NAME),
k8s.namespace.name=$(POD_NAMESPACE)
이름 규약에서 자주 틀리는 것 두 가지를 짚습니다.
첫째, 환경 속성의 이름은 deployment.environment.name입니다. 예전 이름인 deployment.environment는 더 이상 쓰지 않습니다. 이름이 다르면 두 개의 별개 속성이 되고, 대시보드 변수는 그중 하나만 읽습니다.
둘째, service.name은 배포 단위가 아니라 서비스 단위여야 합니다. 같은 코드베이스를 카나리로 두 벌 띄웠다면 둘 다 checkout-api이고, 구분은 service.version이나 별도 속성으로 합니다. 카나리를 checkout-api-canary로 부르는 순간 서비스 그래프에 유령 노드가 생깁니다.
| 속성 | 값의 예 | 카디널리티 | 바꿀 수 있는가 |
|---|---|---|---|
| service.name | checkout-api | 서비스 수 | 사실상 불가 |
| service.namespace | commerce | 팀 수 | 어렵다 |
| service.version | 2.7.1 | 배포 횟수 | 매 배포마다 바뀜 |
| deployment.environment.name | prod | 3~5 | 불가 |
| service.instance.id | 파드 이름 | 파드 수 | 매 재시작마다 바뀜 |
service.instance.id는 카디널리티가 높지만 리소스 속성이므로 트레이스와 로그에서는 문제가 없습니다. 다만 이 속성을 메트릭 레이블로 그대로 승격시키면 시계열이 파드 수만큼 곱해집니다. 컬렉터에서 메트릭 파이프라인에 한해 제거하는 것이 일반적입니다.
3단계 — 컨텍스트 전파가 끊기는 네 지점
전파는 트레이스를 만드는 유일한 메커니즘입니다. 호출하는 쪽이 W3C traceparent 헤더에 현재 트레이스 ID와 스팬 ID를 넣고, 받는 쪽이 그것을 읽어 부모로 삼습니다. 그 검증은 명령 한 줄이면 됩니다.
# 게이트웨이 흉내를 내서 헤더를 직접 넣어 보고, 백엔드에서 이 trace ID 로 조회한다
curl -sS -o /dev/null -w '%{http_code}\n' \
http://checkout-api.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'content-type: application/json' \
-d '{"cart_id":"c-1"}'
# 스팬이 이 trace ID 아래에 붙지 않으면, 아래 네 가지 중 하나다
끊김 1 — 스레드풀과 executor
가장 많이 겪는 유형입니다. 컨텍스트는 스레드 로컬(또는 asyncio contextvar)에 들어 있으므로, 작업을 다른 스레드로 넘기면 그 컨텍스트가 따라가지 않습니다.
# 끊긴다 — 워커 스레드에는 컨텍스트가 없어서 새 트레이스가 시작된다
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=8)
def enrich_all(items):
return list(pool.map(fetch_details, items))
# 살아남는다 — 현재 컨텍스트를 캡처해서 워커 안에서 다시 활성화한다
from concurrent.futures import ThreadPoolExecutor
from opentelemetry import context as otel_context
pool = ThreadPoolExecutor(max_workers=8)
def _with_context(ctx, fn, *args):
token = otel_context.attach(ctx)
try:
return fn(*args)
finally:
otel_context.detach(token)
def enrich_all(items):
ctx = otel_context.get_current()
futures = [pool.submit(_with_context, ctx, fetch_details, it) for it in items]
return [f.result() for f in futures]
Java에서는 Context.current().wrap(runnable), Go에서는 context.Context를 goroutine 인자로 넘기는 것, Node.js에서는 AsyncLocalStorage가 같은 역할을 합니다. 언어마다 이름은 다르지만 원리는 동일합니다. 컨텍스트는 실행 단위를 따라가지 않으므로 명시적으로 옮겨야 합니다.
끊김 2 — 메시지 큐
큐는 프로세스 경계이자 시간 경계입니다. HTTP처럼 헤더가 자동으로 흐르지 않으므로 메시지에 직접 주입해야 합니다.
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("checkout", "2.7.1")
def publish_order(producer, order):
with tracer.start_as_current_span(
"orders publish", kind=SpanKind.PRODUCER
) as span:
span.set_attribute("messaging.system", "kafka")
span.set_attribute("messaging.destination.name", "orders")
headers = {}
propagate.inject(headers) # traceparent 를 dict 에 넣는다
producer.send(
"orders",
value=order.to_bytes(),
headers=[(k, v.encode()) for k, v in headers.items()],
)
컨슈머에서는 추출한 컨텍스트를 부모로 씁니다. 다만 배치로 여러 메시지를 한 번에 처리한다면 부모는 하나뿐이므로 링크를 씁니다.
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind, Link
def consume_batch(messages):
links = []
for m in messages:
headers = {k: v.decode() for k, v in (m.headers or [])}
ctx = propagate.extract(headers)
sc = trace.get_current_span(ctx).get_span_context()
if sc.is_valid:
links.append(Link(sc))
with tracer.start_as_current_span(
"orders process", kind=SpanKind.CONSUMER, links=links
) as span:
span.set_attribute("messaging.batch.message_count", len(messages))
for m in messages:
handle(m)
큐 대기 시간이 몇 분 이상이라면 메시지가 하나여도 링크를 고려합니다. 부모-자식으로 이으면 트레이스 하나의 지속 시간이 대기 시간만큼 늘어나서 백엔드에서 다루기 어려워집니다.
끊김 3 — 백그라운드 작업과 스케줄러
크론, Celery beat, FastAPI의 BackgroundTasks처럼 요청과 무관하게 도는 작업은 부모가 없습니다. 이때 흔한 실수는 요청 컨텍스트를 억지로 이어 붙이는 것입니다. 요청은 이미 응답을 보내고 끝났는데 그 트레이스에 30초짜리 자식이 붙으면 요청 지연 시간 통계가 오염됩니다.
# 배경 작업은 새 루트 트레이스로 시작하고, 원인 요청은 링크로 남긴다
def schedule_reindex(cart_id):
origin = trace.get_current_span().get_span_context()
def run():
links = [Link(origin)] if origin.is_valid else []
with tracer.start_as_current_span(
"cart.reindex", kind=SpanKind.INTERNAL, links=links
) as span:
span.set_attribute("cart.id", cart_id)
reindex(cart_id)
background.add_task(run)
끊김 4 — 헤더를 지우는 중간 계층
프록시, WAF, API 게이트웨이, CDN이 화이트리스트 방식으로 헤더를 필터링하면 traceparent가 조용히 사라집니다. 로그에는 아무것도 남지 않고, 증상은 "게이트웨이 뒤부터 트레이스가 새로 시작된다"입니다.
# 실제로 도착하는 헤더를 확인하는 가장 빠른 방법
kubectl -n commerce exec deploy/checkout-api -- \
sh -c 'timeout 20 tcpdump -A -s0 -i any "tcp port 8000" 2>/dev/null | grep -i traceparent'
# 또는 앱에 임시 엔드포인트를 하나 두고 받은 헤더를 그대로 반환하게 한다
curl -s http://checkout-api.internal/__debug/headers \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' | jq .
Envoy나 Istio를 쓴다면 traceparent, tracestate, baggage가 허용 목록에 있는지 확인합니다. B3 헤더를 쓰는 레거시 서비스가 섞여 있다면 전파기를 복수로 설정합니다.
OTEL_PROPAGATORS=tracecontext,baggage,b3multi
4단계 — 수동 스팬은 self time이 큰 곳에만
1308ms의 공백으로 돌아옵니다. 수동 스팬을 넣을 후보는 다섯 종류입니다.
- 루프와 배치 경계 — 반복 횟수를 속성으로 남깁니다
- 캐시 조회 — 히트 여부를 속성으로 남기면 캐시 효율이 트레이스에서 바로 보입니다
- 계측 패키지가 없는 서드파티 SDK 호출
- 락, 큐, 커넥션 풀 대기
- CPU를 오래 쓰는 구간 — 직렬화, 압축, 리포트 생성
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("checkout", "2.7.1")
async def apply_promotions(cart, tenant_id):
with tracer.start_as_current_span("checkout.apply_promotions") as span:
span.set_attribute("cart.item_count", len(cart.items))
span.set_attribute("tenant.id", tenant_id)
span.set_attribute("promotion.engine", "rules-v3")
try:
with tracer.start_as_current_span("promotion.load_rules") as load:
cached = await rules.from_cache(tenant_id)
load.set_attribute("cache.hit", cached is not None)
ruleset = cached or await rules.compile(tenant_id)
load.set_attribute("promotion.rule_count", len(ruleset))
with tracer.start_as_current_span("promotion.evaluate") as ev:
result = ruleset.evaluate(cart)
ev.set_attribute("promotion.evaluated", result.evaluated)
ev.set_attribute("promotion.matched", len(result.matched))
return result
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise
이 계측을 넣은 뒤 같은 요청의 트레이스는 이렇게 바뀝니다.
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ INTERNAL checkout.apply_promotions 1298ms
│ ├─ INTERNAL promotion.load_rules cache.hit=false 1241ms <-- 여기
│ └─ INTERNAL promotion.evaluate evaluated=812 54ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (self time 12ms)
스팬 이름 규칙 하나만 지키면 됩니다. 이름은 저카디널리티여야 합니다. GET /v1/orders/A-99183이 아니라 GET /v1/orders/:id이고, 구체적인 값은 전부 속성으로 갑니다. 백엔드는 스팬 이름으로 그룹핑해서 지연 시간 통계와 서비스 그래프를 만들기 때문에, 이름에 ID가 들어가면 그 집계 뷰 전체가 무너집니다.
5단계 — 컬렉터를 앱과 백엔드 사이에 두는 이유
SDK가 백엔드로 직접 보내도 동작은 합니다. 그런데도 컬렉터를 두는 이유는 다섯 가지입니다.
- 정책을 재배포 없이 바꿉니다. 샘플링 비율, 속성 필터, 보존 대상은 운영 중에 조정하게 되는 값입니다. 그것이 앱 환경변수에 있으면 20개 서비스를 롤아웃해야 합니다.
- 앱을 백엔드 장애로부터 격리합니다. 백엔드가 느려질 때 SDK의 전송 큐가 차면 앱 메모리가 올라가고, 심하면 요청 처리에 영향을 줍니다. 컬렉터가 앞에 있으면 그 압력을 대신 받습니다.
- 백엔드를 바꿀 수 있습니다. 메트릭은 Prometheus, 트레이스는 ClickHouse, 로그는 OpenSearch처럼 신호별로 다른 목적지를 쓰거나, 두 백엔드를 병행 운영하며 이관하는 일이 컬렉터 설정 파일 한 곳에서 끝납니다.
- 민감 정보를 앱 밖에서 지웁니다. 토큰이나 이메일이 속성에 섞여 들어가는 사고는 반드시 일어납니다. 컬렉터에 방어선을 두면 사고 대응이 재배포가 아니라 설정 변경이 됩니다.
- 테일 샘플링을 할 수 있습니다. 트레이스 결과를 보고 판단하려면 스팬이 한곳에 모여야 하고, 그 장소는 앱이 될 수 없습니다.
배치 크기, 재시도, 메모리 상한을 설정하는 최소 구성입니다.
# otel-collector.yaml — 앱과 같은 노드 또는 사이드카에 두는 에이전트 계층
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# 반드시 첫 번째. 메모리 한계에 닿으면 수신을 거부해서 컬렉터 자체가 죽는 것을 막는다
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
# 쿠버네티스 메타데이터를 리소스 속성으로 붙인다
k8sattributes:
auth_type: serviceAccount
extract:
metadata:
- k8s.namespace.name
- k8s.deployment.name
- k8s.pod.name
- k8s.node.name
# 민감 속성 제거 — 앱을 고치지 않고 여기서 막는다
attributes/redact:
actions:
- key: http.request.header.authorization
action: delete
- key: user.email
action: delete
- key: db.query.text
action: hash
# 항상 마지막. 네트워크 왕복을 줄인다
batch:
timeout: 5s
send_batch_size: 8192
send_batch_max_size: 16384
exporters:
otlp/gateway:
endpoint: otel-gateway.observability.svc:4317
tls:
insecure: true
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
retry_on_failure:
enabled: true
initial_interval: 5s
max_elapsed_time: 300s
service:
telemetry:
metrics:
level: detailed
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
metrics:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/gateway]
logs:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
프로세서 순서가 의미를 가집니다. memory_limiter가 첫 번째가 아니면 과부하 상황에서 컬렉터가 OOM으로 죽고, batch가 마지막이 아니면 뒤따르는 프로세서가 배치를 다시 쪼개면서 이득이 사라집니다. Collector 문서에도 같은 순서를 권장합니다.
컬렉터를 두 계층으로 나누는 것이 일반적입니다. 앱 옆의 에이전트는 수집과 메타데이터 부착만 하고, 게이트웨이 계층에서 테일 샘플링과 백엔드 라우팅을 합니다. 테일 샘플링을 쓴다면 게이트웨이 앞에 trace ID 기준 라우팅이 필요합니다. 같은 트레이스의 스팬이 서로 다른 인스턴스로 흩어지면 각자 조각을 보고 판정해서 트레이스가 무작위로 잘립니다.
계측이 앱을 망가뜨리는 방식들
행복한 경로만 보여 주는 가이드는 쓸모가 없으므로, 실제로 겪게 되는 실패를 모아 둡니다.
| 증상 | 원인 | 확인 방법 | 대응 |
|---|---|---|---|
| 배포 후 메모리가 계속 증가 | 백엔드 응답 지연으로 익스포터 큐가 계속 참 | 컬렉터의 큐 크기 메트릭과 앱 RSS 추이 | 큐 크기 상한과 드롭 정책을 명시, 컬렉터 경유로 전환 |
| 스팬이 일부만 도착 | 프로세스가 종료될 때 flush 없이 죽음 | 배치 프로세서 타임아웃과 종료 훅 | 종료 시 shutdown 호출, 컨테이너 terminationGracePeriod 확대 |
| 스팬 하나가 수백 KB | 요청 본문 전체를 속성에 넣음 | 백엔드의 스팬 크기 분포 | 속성 값 길이 상한 설정 |
| 지연 시간이 눈에 띄게 증가 | 동기 익스포터 또는 뜨거운 루프 안의 스팬 생성 | 계측 전후 벤치마크 | 배치 프로세서 사용, 루프 내부가 아닌 루프 경계에 스팬 |
| 트레이스가 게이트웨이에서 새로 시작 | 프록시가 헤더 제거 | 헤더 덤프 | 허용 목록에 traceparent 추가 |
| 서비스 그래프에 유령 노드 | 카나리를 별도 service.name 으로 배포 | 리소스 속성 확인 | service.name 은 서비스 단위로 고정 |
속성 크기는 SDK 차원에서 상한을 걸어 둘 수 있습니다. 기본값은 속성 개수 128개이고 값 길이는 제한이 없으므로, 명시적으로 지정하는 편이 안전합니다.
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048
OTEL_BSP_MAX_QUEUE_SIZE=4096
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=2000
종료 시 flush는 언어별로 다릅니다. Python 자동 계측은 정상 종료 시 프로바이더를 shutdown 하지만, SIGKILL로 죽으면 큐에 남은 스팬은 사라집니다. 짧은 배치 작업이라면 명시적으로 flush 하는 편이 확실합니다.
from opentelemetry import trace
def main():
run_job()
# 배치 잡은 반드시 명시적으로 비운다
trace.get_tracer_provider().force_flush(timeout_millis=10_000)
trace.get_tracer_provider().shutdown()
계측이 끝났다고 말할 수 있는 기준
체크리스트를 통과해야 다음 단계로 넘어갑니다.
- 임의의 프로덕션 요청 하나를 골라 트레이스를 열었을 때, 관여한 서비스 수와 SERVER 스팬 수가 일치한다
- 루트 스팬의 지속 시간이 게이트웨이 접근 로그의 응답 시간과 오차 범위 안에서 일치한다
- 가장 큰 self time이 전체의 20% 미만이다
- 트레이스에서 trace ID를 복사해 로그 검색에 넣으면 해당 요청의 로그가 나온다
- 스팬 이름 목록을 카디널리티 순으로 정렬했을 때 상위 20개가 라우트 템플릿이지 ID가 아니다
- 메시지 큐를 넘는 작업이 하나의 트레이스나 링크로 이어져 있다
- 컬렉터를 재시작해도 앱이 영향받지 않는다
마지막 항목은 실제로 해 봐야 압니다. 컬렉터를 한 번 죽여 보고 앱의 에러율과 지연 시간이 흔들리지 않는지 확인합니다. 흔들린다면 익스포터가 동기이거나 큐 정책이 잘못된 것입니다.
마치며 — 계측의 가치는 스팬 수가 아니라 트레이스의 완결성에서 나온다
스팬 500개짜리 조각난 트레이스보다 스팬 12개짜리 완결된 트레이스가 압도적으로 유용합니다. 그래서 투자 순서도 정해집니다. 전파가 끊기지 않는 것이 첫째이고, 리소스 속성이 일관된 것이 둘째이며, 수동 스팬은 마지막입니다.
지금 할 수 있는 가장 값싼 검증은 프로덕션 트레이스 하나를 열어서 SERVER 스팬을 세어 보는 것입니다. 요청이 통과한 서비스가 여섯 개인데 SERVER 스팬이 두 개라면, 이번 주에 할 일은 수동 스팬 추가가 아니라 나머지 네 곳의 전파를 살리는 일입니다.
더 파고들 자료입니다.
Instrumenting Your App With OpenTelemetry — From Auto-Instrumentation to Manual Spans, and Why You Put a Collector in Front
- Introduction — When You Have Dashboards but Don't Know Why It's Slow
- Decide the Order First
- Stage 1 — How Far Auto-Instrumentation Alone Gets You
- Stage 2 — You Can't Fix Resource Attributes Later
- Stage 3 — The Four Places Context Propagation Breaks
- Stage 4 — Manual Spans Only Where Self Time Is Large
- Stage 5 — Why You Put a Collector Between the App and the Backend
- Ways Instrumentation Breaks the App
- Closing — Instrumentation's Value Comes From a Trace's Completeness, Not Its Span Count
Introduction — When You Have Dashboards but Don't Know Why It's Slow
The order API's p99 is 1.4 seconds. Grafana already has panels for CPU, memory, request count, and error rate, and they're all green. The database dashboard is normal too. But users say it's slow, and we don't know which code spent that 1.4 seconds.
What's needed in this state isn't one more panel — it's instrumentation. Instrumentation is the work of making the code itself say "what happened, when, and how long it took," and OpenTelemetry is the project that standardizes the format and transport protocol for that telling.
This post instruments one service from start to finish. It uses a Python FastAPI service as the example, but the order is language-agnostic. Verified as of July 2026, against OpenTelemetry Collector v0.157.0, Semantic Conventions v1.43.0, and the Python SDK 1.3x line. Since there are still areas of the semantic conventions where names keep changing, it's safer to always check attribute names in the Semantic Conventions registry for the version you're on.
Decide the Order First
Teams that fail at instrumentation fail almost the same way every time. They start by planting manual spans in the code, and two weeks later they have 300 spans, while traces are still breaking at service boundaries.
Here's an order that actually works.
| Stage | What you do | Time it takes | If you skip this stage |
|---|---|---|---|
| 1 | Turn on auto-instrumentation and confirm traces reach the backend | Half a day | Every debugging session after this becomes guesswork |
| 2 | Lock down resource attributes (service.name, etc.) | Half a day | Changing it later severs the link to past data |
| 3 | Verify propagation stays alive across service boundaries | 1 day | No matter how many spans you add, traces stay fragmented |
| 4 | Add manual spans only where self time is large | Ongoing | Auto-instrumentation's blind spots remain forever |
| 5 | Put a collector in front and hand off processing and sampling | 1 day | Every policy change means redeploying every service |
The key is that step 3 comes before step 4. Adding manual spans while propagation is broken just breaks an already-fragmented trace into finer fragments.
Stage 1 — How Far Auto-Instrumentation Alone Gets You
In Python, the opentelemetry-instrument launcher hooks in installed instrumentation packages at process start. There's no code change.
pip install \
'opentelemetry-distro[otlp]' \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-requests \
opentelemetry-instrumentation-redis \
opentelemetry-instrumentation-logging
# Auto-detects and attaches whichever instrumentation packages are installed
opentelemetry-bootstrap --action=install
Execution is controlled purely through environment variables. This is auto-instrumentation's core advantage. Since the instrumentation config lives in the deployment manifest rather than in code, changing an endpoint or a sampling rate doesn't need a code review.
export OTEL_SERVICE_NAME=checkout-api
export OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod,service.namespace=commerce
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_SAMPLER=parentbased_always_on
export OTEL_PYTHON_LOG_CORRELATION=true
opentelemetry-instrument uvicorn app.main:app --host 0.0.0.0 --port 8000
Starting with parentbased_always_on is recommended. Turn on rate-based sampling from the start, and when a trace doesn't show up, you can't tell whether it's an instrumentation problem or the sampling. Attach sampling in the collector after you've confirmed data is flowing.
What you get from this state is exactly one thing: network boundaries.
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ CLIENT redis GET promo:rules:t-8871 2ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (the remaining 1308ms belongs to no span)
The last line is everything. Auto-instrumentation tells you where the problem isn't, through a 1308ms gap. That gap is called self time, and in stage 4, this is the only place you add manual spans.
What Auto-Instrumentation Never Sees
- CPU work inside the process — serialization, compression, template rendering, encryption, image processing
- Waiting on a lock and waiting on a connection pool — the wait time isn't included in the query span that starts after the connection is acquired
- GIL contention and event-loop delay
- Third-party SDK calls with no instrumentation package
- Branches in business logic — which rules got evaluated, and how many
Stage 2 — You Can't Fix Resource Attributes Later
A resource is a set of attributes describing "what produced this telemetry." Unlike span attributes, resource attributes attach to every signal that process emits. And once set, they're hard to change. The moment you change service.name, the link to dashboards, alerts, the service graph, and past data all breaks at once.
# Minimal set — without these three, you can't tell where the data came from
OTEL_SERVICE_NAME=checkout-api
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,deployment.environment.name=prod
# Additionally useful in practice
OTEL_RESOURCE_ATTRIBUTES=service.version=2.7.1,\
deployment.environment.name=prod,\
service.namespace=commerce,\
service.instance.id=checkout-api-7d9f4b-x2k9m
In Kubernetes, don't hardcode the instance identifier — inject it via the Downward API.
# deployment.yaml
env:
- name: OTEL_SERVICE_NAME
value: checkout-api
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OTEL_RESOURCE_ATTRIBUTES
value: >-
service.version=2.7.1,
deployment.environment.name=prod,
service.namespace=commerce,
service.instance.id=$(POD_NAME),
k8s.namespace.name=$(POD_NAMESPACE)
Here are two things people frequently get wrong about naming conventions.
First, the name of the environment attribute is deployment.environment.name. The old name, deployment.environment, is no longer used. Different names become two separate attributes, and a dashboard variable only reads one of them.
Second, service.name should be scoped to the service, not the deployment unit. If you've stood up the same codebase twice for a canary, both are checkout-api, and you distinguish them with service.version or a separate attribute. The moment you name the canary checkout-api-canary, a phantom node appears in the service graph.
| Attribute | Example value | Cardinality | Can it be changed |
|---|---|---|---|
| service.name | checkout-api | Number of services | Effectively no |
| service.namespace | commerce | Number of teams | Difficult |
| service.version | 2.7.1 | Number of deploys | Changes with every deploy |
| deployment.environment.name | prod | 3–5 | No |
| service.instance.id | Pod name | Number of pods | Changes on every restart |
service.instance.id is high-cardinality, but since it's a resource attribute, it's not a problem for traces and logs. However, if you promote this attribute directly into a metric label, your time series get multiplied by the pod count. It's common to strip it in the collector, but only for the metrics pipeline.
Stage 3 — The Four Places Context Propagation Breaks
Propagation is the sole mechanism that creates a trace. The caller puts the current trace ID and span ID into the W3C traceparent header, and the callee reads it and uses it as the parent. Verifying this takes just one command.
# Impersonate the gateway by injecting the header directly, then look this trace ID up in the backend
curl -sS -o /dev/null -w '%{http_code}\n' \
http://checkout-api.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'content-type: application/json' \
-d '{"cart_id":"c-1"}'
# If a span doesn't end up under this trace ID, it's one of the four cases below
Break 1 — Thread Pools and Executors
This is the type you run into most. Since the context lives in thread-local storage (or an asyncio contextvar), handing work off to a different thread doesn't carry that context along.
# Breaks — the worker thread has no context, so a new trace starts
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=8)
def enrich_all(items):
return list(pool.map(fetch_details, items))
# Survives — capture the current context and reactivate it inside the worker
from concurrent.futures import ThreadPoolExecutor
from opentelemetry import context as otel_context
pool = ThreadPoolExecutor(max_workers=8)
def _with_context(ctx, fn, *args):
token = otel_context.attach(ctx)
try:
return fn(*args)
finally:
otel_context.detach(token)
def enrich_all(items):
ctx = otel_context.get_current()
futures = [pool.submit(_with_context, ctx, fetch_details, it) for it in items]
return [f.result() for f in futures]
In Java, Context.current().wrap(runnable); in Go, passing a context.Context as a goroutine argument; in Node.js, AsyncLocalStorage — these play the same role. The name differs by language, but the principle is identical. Context doesn't follow the unit of execution, so you have to move it explicitly.
Break 2 — Message Queues
A queue is both a process boundary and a time boundary. Headers don't flow automatically the way they do with HTTP, so you have to inject them into the message directly.
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("checkout", "2.7.1")
def publish_order(producer, order):
with tracer.start_as_current_span(
"orders publish", kind=SpanKind.PRODUCER
) as span:
span.set_attribute("messaging.system", "kafka")
span.set_attribute("messaging.destination.name", "orders")
headers = {}
propagate.inject(headers) # put traceparent into the dict
producer.send(
"orders",
value=order.to_bytes(),
headers=[(k, v.encode()) for k, v in headers.items()],
)
On the consumer side, use the extracted context as the parent. But if you're processing several messages at once as a batch, there's only one parent, so use links instead.
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind, Link
def consume_batch(messages):
links = []
for m in messages:
headers = {k: v.decode() for k, v in (m.headers or [])}
ctx = propagate.extract(headers)
sc = trace.get_current_span(ctx).get_span_context()
if sc.is_valid:
links.append(Link(sc))
with tracer.start_as_current_span(
"orders process", kind=SpanKind.CONSUMER, links=links
) as span:
span.set_attribute("messaging.batch.message_count", len(messages))
for m in messages:
handle(m)
If queue wait time runs several minutes or more, consider using a link even for a single message. Chain it as parent-child, and one trace's duration stretches out by the wait time, which makes it unwieldy for the backend to handle.
Break 3 — Background Jobs and Schedulers
Work that runs independently of any request — cron, Celery beat, FastAPI's BackgroundTasks — has no parent. A common mistake here is forcibly stitching in the request context. The request already sent its response and ended, so if a 30-second child gets attached to that trace anyway, it contaminates the request latency statistics.
# Background work starts as a new root trace; the causing request is left as a link
def schedule_reindex(cart_id):
origin = trace.get_current_span().get_span_context()
def run():
links = [Link(origin)] if origin.is_valid else []
with tracer.start_as_current_span(
"cart.reindex", kind=SpanKind.INTERNAL, links=links
) as span:
span.set_attribute("cart.id", cart_id)
reindex(cart_id)
background.add_task(run)
Break 4 — Intermediate Layers That Strip Headers
When a proxy, WAF, API gateway, or CDN filters headers by allowlist, traceparent silently disappears. Nothing gets logged, and the symptom is "the trace starts fresh right after the gateway."
# The fastest way to see which headers actually arrive
kubectl -n commerce exec deploy/checkout-api -- \
sh -c 'timeout 20 tcpdump -A -s0 -i any "tcp port 8000" 2>/dev/null | grep -i traceparent'
# Or add a temporary endpoint to the app that echoes back whatever headers it received
curl -s http://checkout-api.internal/__debug/headers \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' | jq .
If you're using Envoy or Istio, check that traceparent, tracestate, and baggage are on the allowlist. If legacy services using B3 headers are mixed in, configure multiple propagators.
OTEL_PROPAGATORS=tracecontext,baggage,b3multi
Stage 4 — Manual Spans Only Where Self Time Is Large
Back to the 1308ms gap. There are five candidate types of places to add manual spans.
- Loop and batch boundaries — record the iteration count as an attribute
- Cache lookups — record hit/miss as an attribute, and cache efficiency becomes visible directly in the trace
- Third-party SDK calls with no instrumentation package
- Waiting on a lock, a queue, or a connection pool
- Stretches that spend a long time on CPU — serialization, compression, report generation
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("checkout", "2.7.1")
async def apply_promotions(cart, tenant_id):
with tracer.start_as_current_span("checkout.apply_promotions") as span:
span.set_attribute("cart.item_count", len(cart.items))
span.set_attribute("tenant.id", tenant_id)
span.set_attribute("promotion.engine", "rules-v3")
try:
with tracer.start_as_current_span("promotion.load_rules") as load:
cached = await rules.from_cache(tenant_id)
load.set_attribute("cache.hit", cached is not None)
ruleset = cached or await rules.compile(tenant_id)
load.set_attribute("promotion.rule_count", len(ruleset))
with tracer.start_as_current_span("promotion.evaluate") as ev:
result = ruleset.evaluate(cart)
ev.set_attribute("promotion.evaluated", result.evaluated)
ev.set_attribute("promotion.matched", len(result.matched))
return result
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise
After adding this instrumentation, the trace for the same request changes to this.
SERVER checkout-api POST /v1/orders 1421ms
├─ CLIENT GET http://auth.internal/verify 31ms
├─ CLIENT SELECT carts WHERE id = ? 6ms
├─ INTERNAL checkout.apply_promotions 1298ms
│ ├─ INTERNAL promotion.load_rules cache.hit=false 1241ms <-- here
│ └─ INTERNAL promotion.evaluate evaluated=812 54ms
├─ CLIENT POST http://payment.internal/charge 74ms
└─ (self time 12ms)
There's just one span-naming rule to keep: names must be low-cardinality. It's GET /v1/orders/:id, not GET /v1/orders/A-99183 — every concrete value goes into an attribute instead. Since the backend groups by span name to build latency statistics and the service graph, an ID in the name collapses that entire aggregate view.
Stage 5 — Why You Put a Collector Between the App and the Backend
Having the SDK send directly to the backend does work. Even so, there are five reasons to put a collector in front.
- Change policy without redeploying. Sampling rate, attribute filters, and what gets retained are values you end up tuning while running. If they live in app environment variables, you have to roll out 20 services.
- Isolate the app from backend outages. When the backend slows down, if the SDK's export queue fills up, app memory climbs, and in bad cases it affects request handling. With a collector in front, it absorbs that pressure instead.
- You can swap backends. Sending metrics to Prometheus, traces to ClickHouse, logs to OpenSearch — using a different destination per signal, or running two backends in parallel while migrating — all of it ends in one place: the collector's config file.
- Strip sensitive data outside the app. An incident where a token or an email gets mixed into an attribute is bound to happen. With a defensive layer in the collector, incident response becomes a config change instead of a redeploy.
- You can do tail sampling. To judge based on a trace's outcome, its spans have to converge in one place, and that place can't be the app.
Here's a minimal configuration that sets batch size, retries, and a memory ceiling.
# otel-collector.yaml — the agent layer, placed on the same node as the app or as a sidecar
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# Must be first. Once it hits the memory limit, it refuses incoming data to keep the collector itself from dying
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
# Attach Kubernetes metadata as resource attributes
k8sattributes:
auth_type: serviceAccount
extract:
metadata:
- k8s.namespace.name
- k8s.deployment.name
- k8s.pod.name
- k8s.node.name
# Strip sensitive attributes — block it here without touching the app
attributes/redact:
actions:
- key: http.request.header.authorization
action: delete
- key: user.email
action: delete
- key: db.query.text
action: hash
# Always last. Cuts down network round trips
batch:
timeout: 5s
send_batch_size: 8192
send_batch_max_size: 16384
exporters:
otlp/gateway:
endpoint: otel-gateway.observability.svc:4317
tls:
insecure: true
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
retry_on_failure:
enabled: true
initial_interval: 5s
max_elapsed_time: 300s
service:
telemetry:
metrics:
level: detailed
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
metrics:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/gateway]
logs:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, attributes/redact, batch]
exporters: [otlp/gateway]
Processor order carries meaning. If memory_limiter isn't first, the collector dies to OOM under overload; if batch isn't last, a processor downstream of it re-splits the batch and the benefit disappears. The Collector documentation recommends the same order.
It's common to split the collector into two layers. The agent next to the app only collects and attaches metadata; the gateway layer does tail sampling and backend routing. If you use tail sampling, you need trace-ID-based routing in front of the gateway. If spans from the same trace scatter across different instances, each one judges based on its own fragment, and traces get cut at random.
Ways Instrumentation Breaks the App
A guide that shows only the happy path is useless, so here's a collection of failures you'll actually run into.
| Symptom | Cause | How to check | Response |
|---|---|---|---|
| Memory keeps climbing after deploy | Exporter queue stays full from backend response delay | Collector queue-size metric and app RSS trend | Set an explicit queue-size cap and drop policy, route through the collector |
| Only part of the spans arrive | Process died without flushing on exit | Batch processor timeout and shutdown hook | Call shutdown on exit, extend the container's terminationGracePeriod |
| A single span is hundreds of KB | An entire request body went into an attribute | Span-size distribution in the backend | Set a length cap on attribute values |
| Latency visibly increases | A synchronous exporter, or span creation inside a hot loop | Benchmark before and after instrumenting | Use the batch processor, span the loop's boundary, not its interior |
| Trace starts fresh at the gateway | A proxy strips headers | Header dump | Add traceparent to the allowlist |
| Phantom node in the service graph | Canary deployed under a separate service.name | Check resource attributes | Keep service.name fixed at the service level |
You can cap attribute size at the SDK level. The default is 128 attributes with no limit on value length, so specifying it explicitly is safer.
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048
OTEL_BSP_MAX_QUEUE_SIZE=4096
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=2000
Flushing on shutdown differs by language. Python auto-instrumentation shuts down the provider on a graceful exit, but if it's killed with SIGKILL, any spans left in the queue vanish. For short batch jobs, flushing explicitly is the surer approach.
from opentelemetry import trace
def main():
run_job()
# A batch job must always flush explicitly
trace.get_tracer_provider().force_flush(timeout_millis=10_000)
trace.get_tracer_provider().shutdown()
The Bar for Saying Instrumentation Is Done
You have to clear this checklist before moving to the next stage.
- Pick any production request at random, open its trace, and the number of services involved matches the number of SERVER spans
- The root span's duration matches the response time in the gateway access log, within margin of error
- The largest self time is under 20% of the total
- Copy the trace ID from a trace, put it into log search, and that request's logs come back
- When the span-name list is sorted by cardinality, the top 20 are route templates, not IDs
- Work that crosses a message queue is linked together as one trace or via links
- Restarting the collector doesn't affect the app
The last item you only know by actually doing it. Kill the collector once and check whether the app's error rate and latency stay steady. If they wobble, the exporter is synchronous or the queue policy is wrong.
Closing — Instrumentation's Value Comes From a Trace's Completeness, Not Its Span Count
A complete trace with 12 spans is overwhelmingly more useful than a fragmented one with 500. So the order of investment follows from that too. Unbroken propagation comes first, consistent resource attributes come second, and manual spans come last.
The cheapest verification you can do right now is to open one production trace and count the SERVER spans. If a request passed through six services but there are only two SERVER spans, this week's job isn't adding manual spans — it's reviving propagation at the other four.
Further reading.