Skip to content

Split View: 애플리케이션에 OpenTelemetry 붙이기 — 자동 계측에서 수동 스팬까지, 그리고 컬렉터를 두는 이유

✨ Learn with Quiz
|

애플리케이션에 OpenTelemetry 붙이기 — 자동 계측에서 수동 스팬까지, 그리고 컬렉터를 두는 이유

들어가며 — 대시보드는 있는데 왜 느린지는 모를 때

주문 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일스팬을 아무리 늘려도 트레이스가 조각납니다
4self 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.namecheckout-api서비스 수사실상 불가
service.namespacecommerce팀 수어렵다
service.version2.7.1배포 횟수매 배포마다 바뀜
deployment.environment.nameprod3~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의 공백으로 돌아옵니다. 수동 스팬을 넣을 후보는 다섯 종류입니다.

  1. 루프와 배치 경계 — 반복 횟수를 속성으로 남깁니다
  2. 캐시 조회 — 히트 여부를 속성으로 남기면 캐시 효율이 트레이스에서 바로 보입니다
  3. 계측 패키지가 없는 서드파티 SDK 호출
  4. 락, 큐, 커넥션 풀 대기
  5. 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가 백엔드로 직접 보내도 동작은 합니다. 그런데도 컬렉터를 두는 이유는 다섯 가지입니다.

  1. 정책을 재배포 없이 바꿉니다. 샘플링 비율, 속성 필터, 보존 대상은 운영 중에 조정하게 되는 값입니다. 그것이 앱 환경변수에 있으면 20개 서비스를 롤아웃해야 합니다.
  2. 앱을 백엔드 장애로부터 격리합니다. 백엔드가 느려질 때 SDK의 전송 큐가 차면 앱 메모리가 올라가고, 심하면 요청 처리에 영향을 줍니다. 컬렉터가 앞에 있으면 그 압력을 대신 받습니다.
  3. 백엔드를 바꿀 수 있습니다. 메트릭은 Prometheus, 트레이스는 ClickHouse, 로그는 OpenSearch처럼 신호별로 다른 목적지를 쓰거나, 두 백엔드를 병행 운영하며 이관하는 일이 컬렉터 설정 파일 한 곳에서 끝납니다.
  4. 민감 정보를 앱 밖에서 지웁니다. 토큰이나 이메일이 속성에 섞여 들어가는 사고는 반드시 일어납니다. 컬렉터에 방어선을 두면 사고 대응이 재배포가 아니라 설정 변경이 됩니다.
  5. 테일 샘플링을 할 수 있습니다. 트레이스 결과를 보고 판단하려면 스팬이 한곳에 모여야 하고, 그 장소는 앱이 될 수 없습니다.

배치 크기, 재시도, 메모리 상한을 설정하는 최소 구성입니다.

# 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

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.

StageWhat you doTime it takesIf you skip this stage
1Turn on auto-instrumentation and confirm traces reach the backendHalf a dayEvery debugging session after this becomes guesswork
2Lock down resource attributes (service.name, etc.)Half a dayChanging it later severs the link to past data
3Verify propagation stays alive across service boundaries1 dayNo matter how many spans you add, traces stay fragmented
4Add manual spans only where self time is largeOngoingAuto-instrumentation's blind spots remain forever
5Put a collector in front and hand off processing and sampling1 dayEvery 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.

AttributeExample valueCardinalityCan it be changed
service.namecheckout-apiNumber of servicesEffectively no
service.namespacecommerceNumber of teamsDifficult
service.version2.7.1Number of deploysChanges with every deploy
deployment.environment.nameprod3–5No
service.instance.idPod nameNumber of podsChanges 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.

  1. Loop and batch boundaries — record the iteration count as an attribute
  2. Cache lookups — record hit/miss as an attribute, and cache efficiency becomes visible directly in the trace
  3. Third-party SDK calls with no instrumentation package
  4. Waiting on a lock, a queue, or a connection pool
  5. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

SymptomCauseHow to checkResponse
Memory keeps climbing after deployExporter queue stays full from backend response delayCollector queue-size metric and app RSS trendSet an explicit queue-size cap and drop policy, route through the collector
Only part of the spans arriveProcess died without flushing on exitBatch processor timeout and shutdown hookCall shutdown on exit, extend the container's terminationGracePeriod
A single span is hundreds of KBAn entire request body went into an attributeSpan-size distribution in the backendSet a length cap on attribute values
Latency visibly increasesA synchronous exporter, or span creation inside a hot loopBenchmark before and after instrumentingUse the batch processor, span the loop's boundary, not its interior
Trace starts fresh at the gatewayA proxy strips headersHeader dumpAdd traceparent to the allowlist
Phantom node in the service graphCanary deployed under a separate service.nameCheck resource attributesKeep 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.