콘텐츠로 이동

분산 트레이스 내보내기 (OTLP)

Continuum Router는 자신이 생성한 스팬을 OTLP로 OpenTelemetry 컬렉터에 보낼 수 있습니다. 내보내는 스팬은 요청이 라우터를 어떻게 통과했는지를 담습니다. 어떤 라우트로 들어왔고, 어떤 전략으로 어떤 백엔드가 선택됐고, 몇 번 시도했고, 각 시도가 어떻게 끝났는지입니다. 프롬프트 텍스트나 응답 텍스트, 자격 증명은 전혀 포함되지 않습니다.

이 문서에서는 기존 트레이스 컨텍스트 전파와 무엇이 다른지, 어떻게 빌드하고 설정하는지, 정확히 무엇이 나가는지, 프라이버시 경계가 어떻게 강제되는지를 설명합니다.

이 기능이 무엇이고, 무엇이 아닌지

라우터는 예전부터 트레이스 컨텍스트를 전파해 왔습니다. 들어온 요청에서 트레이스 ID를 추출하고, 클라이언트가 보내지 않았으면 생성하며, X-Request-ID, X-Trace-ID, X-Correlation-ID와 W3C traceparent / tracestate 쌍을 백엔드로 전달합니다. 이 동작은 분산 추적에 설명돼 있고 이 문서와는 별개입니다.

새로 추가된 것은 스팬 내보내기(export) 입니다. 라우터가 자체 OpenTelemetry 스팬을 만들어 컬렉터로 보냅니다.

내보내기가 바꾸지 않는 것

  • tracing.otlp가 없거나 enabled: false이면, 인바운드·아웃바운드 트레이스 컨텍스트 동작은 내보내기 기능이 생기기 전과 바이트 단위로 동일합니다. 같은 헤더를 읽고 같은 헤더를 같은 값으로 씁니다.
  • 내보내기는 요청 경로의 성공·실패에 관여하지 않습니다. 컬렉터가 죽었거나 닿지 않거나 내보내기를 거부하면 로그 경고와 스팬 유실이 생길 뿐, 클라이언트 요청이 실패하거나 느려지지 않습니다.
  • 메트릭의 대체재가 아닙니다. 요청률·지연·오류 수는 여전히 메트릭과 모니터링의 Prometheus 엔드포인트에서 얻습니다.

빌드 요구사항

내보내기는 otel Cargo 피처 뒤에 있고, 이 피처는 default에도 full에도 포함되지 않습니다. 피처 없이 빌드하면 opentelemetry, opentelemetry_sdk, opentelemetry-otlp, tracing-opentelemetry를 아예 해석하지 않습니다.

cargo build --release --features otel

공식 릴리스 바이너리는 otel을 포함해 빌드됩니다. 다만 런타임 스위치 tracing.otlp.enabled의 기본값은 여전히 false이므로, 내보내기는 빌드 플래그와 설정 양쪽에서 두 번 옵트인해야 켜집니다.

피처 없이 빌드한 바이너리의 동작

기본 빌드도 tracing.otlp 섹션 전체를 otel 빌드와 똑같이 파싱하고 검증합니다. 덕분에 하나의 config.yaml을 빌드 종류와 무관하게 쓸 수 있고, continuum-router config validate도 어느 쪽에서든 같은 결과를 냅니다. 그런 바이너리가 tracing.otlp.enabled: true 상태로 시작하면 스팬 내보내기가 비활성 상태라는 경고를 남기고 평소대로 트래픽을 처리합니다. 동작하지 않는 관측 설정 하나 때문에 라우팅을 거부할 이유는 없기 때문입니다.

설정

아래 블록의 모든 필드는 실제 기본값으로 적어 두었기 때문에, enabled를 빼면 섹션을 통째로 생략한 것과 같습니다.

tracing:
  enabled: true                              # 내보내기와 무관: 헤더 전파 설정
  w3c_trace_context: true

  otlp:
    enabled: false                           # 기본값: false. 시작 시 익스포터 설치 여부
    endpoint: "http://localhost:4318/v1/traces"  # http는 traces 전체 경로, grpc는 베이스 엔드포인트
    protocol: http                           # 기본값: http. http 또는 grpc
    timeout: "10s"                           # 기본값: 10s. 내보내기 요청 하나의 제한 시간

    # 모든 내보내기 요청에 함께 보내는 헤더. 값에 ${ENV_VAR}와
    # ${ENV_VAR:-default} 치환을 쓸 수 있습니다. 비밀 값을 직접 적지 마세요.
    headers:
      Authorization: "Bearer ${OTLP_AUTH_TOKEN}"

    sampling:
      ratio: 0.05                            # 기본값: 0.05. 루트 트레이스 샘플링 비율, 0.0~1.0
      parent_based: true                     # 기본값: true. 인바운드 샘플링 결정을 따를지 여부

    batch:
      max_queue_size: 2048                   # 기본값: 2048. 이 수를 넘으면 새 스팬을 버립니다
      max_export_batch_size: 512             # 기본값: 512. 내보내기 요청 하나에 담는 스팬 수
      scheduled_delay: "5s"                  # 기본값: 5s. 예약 플러시 간격

    resource:
      service_name: "continuum-router"       # 기본값: "continuum-router"
      service_namespace: null                # 기본값: 없음. service.namespace로 설정됩니다
      deployment_environment: null           # 기본값: 없음. deployment.environment.name으로 설정됩니다
      attributes: {}                         # 기본값: 비어 있음. 추가 문자열 리소스 속성

    shutdown_timeout: "5s"                   # 기본값: 5s. 종료 시 마지막 플러시 제한 시간

비밀 값 처리

저장소에 커밋되는 설정 파일의 헤더 값에는 자격 증명을 그대로 적으면 안 됩니다. api_keys, web_search, control_plane이 쓰는 것과 같은 ${ENV_VAR} 치환을 쓰고 값은 환경에서 공급하세요:

export OTLP_AUTH_TOKEN="..."

검증은 의도적으로 치환 전 템플릿을 대상으로 수행합니다. 그래서 continuum-router config validate가 개발 노트북과 실제 배포에서 같은 판정을 내리고, 설정 파일을 확인하려고 비밀 값을 갖다 놓을 필요가 없습니다. 치환은 익스포터를 만들 때 한 번만 일어납니다. 값이 빈 문자열로 치환된 헤더는 빈 채로 보내는 대신 아예 제외합니다. :-default가 없는 ${VAR}의 변수가 설정돼 있지 않으면 문자열이 그대로 남고, 값이 아니라 변수 이름만 담은 경고가 로그에 남습니다.

필드 정의

필드 기본값 설명
enabled false 시작 시 익스포터를 설치합니다. otel 빌드가 아니면 동작하지 않습니다.
endpoint http://localhost:4318/v1/traces 호스트를 포함한 올바른 http/https URL이어야 합니다. 경로 규칙은 프로토콜마다 다릅니다(아래 참고).
protocol http http(OTLP/HTTP protobuf, 컬렉터 4318 포트) 또는 grpc(OTLP/gRPC, 4317 포트).
timeout 10s 내보내기 요청 하나의 제한 시간. 양수 duration, 최대 24h.
headers 비어 있음 최대 16개. 키는 RFC 7230 토큰 문자여야 합니다. 값은 비어 있지 않고 512자 이하이며 제어 문자를 포함할 수 없습니다.
sampling.ratio 0.05 [0.0, 1.0] 범위의 유한한 값.
sampling.parent_based true 인바운드 traceparent의 샘플링 결정을 따릅니다.
batch.max_queue_size 2048 범위는 1..=1_048_576.
batch.max_export_batch_size 512 범위는 1..=1_048_576이고 max_queue_size 이하여야 합니다.
batch.scheduled_delay 5s 양수 duration, 최대 24h.
resource.service_name continuum-router 1~512자. service.name이 됩니다.
resource.service_namespace 없음 설정하면 service.namespace가 됩니다.
resource.deployment_environment 없음 설정하면 deployment.environment.name이 됩니다. 예: production.
resource.attributes 비어 있음 최대 32개. 키는 영숫자와 ., _, -, /만 허용하고 값은 512자 이하입니다.
shutdown_timeout 5s 양수 duration, 최대 24h. 마지막 플러시를 제한합니다.

라우터는 모든 스팬에 자기 크레이트 버전을 service.version으로 자동으로 붙입니다. 직접 설정하는 값이 아닙니다.

리소스 속성은 트래픽이 아니라 배포를 설명하는 값입니다. 리전이나 클러스터처럼 카디널리티가 낮은 배포 메타데이터로 제한하고, 요청별·테넌트별 식별자는 절대 넣지 마세요.

HTTP와 gRPC 엔드포인트

두 프로토콜은 엔드포인트의 의미가 다릅니다. 라우터는 이를 추측하지 않고 프로토콜별로 검증합니다.

protocol: http

엔드포인트는 traces 전체 경로입니다. 호스트만 적으면 검증 단계에서 올바른 형태를 알려주며 거부됩니다:

otlp:
  protocol: http
  endpoint: "http://collector.example.com:4318/v1/traces"   # 올바름
otlp:
  protocol: http
  endpoint: "http://collector.example.com:4318"             # 거부됨: traces 경로 없음

전송은 라우터가 이미 쓰고 있는 비동기 reqwest 클라이언트를 재사용하며 rustls 전용입니다. 블로킹 HTTP 클라이언트도, OpenSSL도 그래프에 들어오지 않습니다.

protocol: grpc

엔드포인트는 경로 없는 베이스 엔드포인트입니다:

otlp:
  protocol: grpc
  endpoint: "https://collector.example.com:4317"

https gRPC 엔드포인트는 ring 프로바이더 기반 rustls와 번들된 webpki 루트 인증서를 사용합니다. OS 인증서 저장소는 참조하지 않습니다. 따라서 webpki 집합에 없는 사설 CA 인증서를 제시하는 컬렉터는 같은 호스트에서 curl이 성공하더라도 검증에 실패합니다.

설정한 헤더는 gRPC 메타데이터가 됩니다. 메타데이터 키는 소문자 ASCII여야 하므로 키는 내보낼 때 소문자로 바뀝니다. 그래도 변환할 수 없는 헤더는 값이 아닌 헤더 이름만 남긴 경고와 함께 제외됩니다.

샘플링

sampling.ratio의 기본값은 0.05, 즉 루트 트레이스 20개 중 1개입니다. 기본값을 1.0보다 한참 낮게 잡은 것은 의도적입니다. 프로덕션 추론 트래픽 앞단의 라우터에서 전량 샘플링을 켜면 백엔드가 아니라 컬렉터가 병목이 되기 쉽습니다. 올릴 때는 의식적으로 올리고, 가능하면 스테이징에서 먼저 시험하세요.

sampling.parent_based의 기본값은 true이고, 이 값이 라우터를 상위에서 시작된 트레이스의 온전한 참여자로 만듭니다:

  • 인바운드 traceparent가 샘플링된 트레이스라고 말하면 ratio와 무관하게 라우터 스팬도 기록됩니다. 상위 트레이스에 라우터 구간만 비는 일이 없습니다.
  • 인바운드 traceparent가 샘플링되지 않은 트레이스라고 말하면 라우터도 아무것도 기록하지 않습니다. 샘플링되지 않은 트레이스에 조각만 덧붙지 않습니다.
  • 인바운드 traceparent가 없으면 라우터가 루트가 되고 ratio가 결정합니다.

parent_based: false로 두면 부모가 샘플링됐더라도 모든 트레이스에 ratio가 적용됩니다. 그 결과 라우터 구간이 빠진 트레이스가 생기므로, 라우터를 의도적으로 독립된 트레이스 소스로 취급할 때만 합리적인 선택입니다.

배치와 백프레셔

종료된 스팬은 크기가 제한된 큐에 들어가고 백그라운드 프로세서가 배치 단위로 내보냅니다.

프로세서는 큐가 가득 차면 생산 스레드를 막는 대신 스팬을 버립니다. 설계의 핵심에 있는 의도적 교환입니다. 스팬 유실은 복구 가능하지만 추론 트래픽 정체는 그렇지 않습니다. 느리거나 멎었거나 닿지 않는 컬렉터가 프록시 요청의 지연을 늘리는 일은 발생하지 않습니다.

설정 효과
max_queue_size 내보내기를 기다릴 수 있는 스팬 수입니다. 가득 차면 새 스팬을 즉시 버립니다. 컬렉터의 긴 장애를 견디려면 메모리를 대가로 올립니다.
max_export_batch_size 내보내기 요청 하나에 담는 스팬 수입니다. max_queue_size를 넘을 수 없습니다. 배치가 클수록 요청 수는 줄고 요청 하나는 커집니다.
scheduled_delay 예약 플러시 사이의 간격입니다. max_export_batch_size만큼 쌓이면 즉시 나가므로, 이 값은 트래픽이 많은 라우터의 처리량이 아니라 한산한 라우터의 지연을 제한합니다.

배치별 내보내기 타임아웃은 없습니다

해당 설정은 OpenTelemetry SDK의 실험적 async-runtime 프로세서에서만 제공되며, 조용히 아무 일도 하지 않는 설정은 아예 없는 설정보다 나쁩니다. 그래서 설정 표면에서 일부러 뺐습니다. 컬렉터가 응답을 멈췄을 때 실제로 의미가 있는 경계인 개별 내보내기 요청은 tracing.otlp.timeout이 제한합니다.

종료

정상 종료 과정에서 라우터는 새 스팬 생성을 먼저 멈춘 뒤, 이미 큐에 있는 스팬을 shutdown_timeout(기본 5s) 안에서 플러시합니다.

그 시간이 지나도 남아 있는 스팬은 버려집니다. 종료는 그대로 진행되고 그 사실을 알리는 경고가 남습니다. 느린 컬렉터로 잔여분을 밀어 넣겠다고, 슈퍼바이저나 오케스트레이터가 재고 있는 종료 시간을 늘릴 가치는 없습니다.

내보내는 스팬과 이벤트

내보내는 범위는 고정된 분류 체계입니다. 아래 표에 없는 것은 나가지 않습니다.

스팬

스팬 이름 종류 속성
router.request server http.request.method, http.route, router.trace_id, http.response.status_code, router.experiment, router.experiment_variant
router.select_backend internal router.selection_strategy, router.model, router.selected_backend
router.backend_call client router.backend_name, router.backend_type, router.model, router.attempt, router.streaming, http.response.status_code, router.outcome

router.request는 요청 미들웨어가 만들고 나머지 스팬은 모두 그 아래에 중첩됩니다. router.select_backend는 전략 평가와 그 결과 선택을 담습니다. router.backend_call은 아웃바운드 시도 하나를 담으므로, 재시도한 요청은 여러 개가 생기고 router.attempt로 구분됩니다.

스팬 이벤트

이벤트 이름 속성
router.retry router.backend_name, router.attempt, router.retry_reason
router.circuit_breaker router.backend_name, router.circuit_state_from, router.circuit_state_to

router.retry는 마지막 시도에서 발생한 것을 포함해 재시도 가능한 실패마다 발생하므로, 설명 없이 끝나는 트레이스가 생기지 않습니다. router.circuit_breaker는 브레이커 전이가 일어나는 단일 지점에서 발생하므로, 브레이커를 움직이는 모든 경로가 정확히 하나의 이벤트를 만듭니다.

속성 값

속성
http.route 매칭된 라우트 템플릿입니다. 예를 들어 /v1/chat/completions이고, axum이 라우트를 매칭하지 못하면 unmatched입니다. 원본 요청 대상은 쓰지 않습니다. 호출자가 넣은 텍스트가 섞일 수 있고 스팬 카디널리티가 무한해지기 때문입니다.
router.trace_id 구조화 로그와 백엔드로 전달되는 헤더에 나타나는 것과 같은 트레이스 ID입니다.
router.experiment, router.experiment_variant 요청을 해석한 모델 실험의 이름과 변형 ID이며, 실험이 요청을 해석한 경우에만 설정됩니다. 둘 다 검증된 설정 식별자([A-Za-z0-9._-]{1,64})이고 요청 텍스트가 아닙니다.
router.selection_strategy 설정된 전략 값입니다. 예: RoundRobin, LeastLatency.
router.backend_type 백엔드에 설정된 프로바이더 타입이고, 확인할 수 없으면 unknown입니다.
router.model 요청된 모델 이름이고, 없으면 <unspecified>입니다.
router.outcome success, 또는 제한된 실패 분류자 중 하나: backend_unavailable, timeout, bad_gateway, rate_limited, connection_error, all_backends_unhealthy, model_not_found, stream_error, invalid_request, unauthorized, forbidden, config_error, internal_error.
router.retry_reason router.outcome과 같은 분류자 집합입니다.
router.circuit_state_from, router.circuit_state_to closed, open, half_open.

여기에 더해 OpenTelemetry 제어 필드 세 개(otel.name, otel.kind, otel.status_code)가 스팬마다 함께 나갑니다. 데이터가 아니라 스팬의 이름·종류·상태를 설정하는 값입니다.

프라이버시: 절대 나가지 않는 것

내보내는 스팬은 메타데이터뿐입니다. 다음은 스팬, 속성, 이벤트, 상태 메시지 어디에도 나타나지 않습니다:

  • 프롬프트 텍스트와 응답 텍스트
  • 요청 본문과 응답 본문(일부 포함)
  • 프로바이더의 오류 본문과 오류 메시지
  • API 키, 베어러 토큰, 프로바이더 자격 증명
  • 원본 요청 경로와 쿼리 문자열

실패 정보는 라우터 오류를 위에 나열한 고정 문자열 중 하나로 매핑하는 분류자를 통해서만 스팬에 도달합니다. 그 지점에서 메시지, 백엔드가 작성한 본문, 모든 오류 페이로드가 버려지므로 프로바이더의 오류 텍스트가 스팬 속성이 되는 경로 자체가 없습니다.

두 겹의 강제 장치

이 보장은 프록시 경로 어딘가의 부주의한 tracing::info_span!(prompt = %body)를 리뷰어가 알아채리라는 기대에 기대지 않습니다. 서로 독립적인 두 지점에서 강제합니다.

  1. 생성 시점. 라우터가 만드는 모든 스팬과 이벤트는 단일 모듈의 생성자에서, 고정된 정적 필드 이름 집합으로만 만들어집니다. 임의의 키/값 쌍을 받는 생성자가 없기 때문에 계획에 없던 필드를 붙일 방법 자체가 없습니다.
  2. 내보내기 경계. 변환 이후 익스포터 직전에 스팬 프로세서가 돌면서, 어느 코드가 만들었든 나갈 내용을 전부 봅니다. 이 프로세서는 다음을 버립니다:
    • 분류 체계의 세 이름에 없는 스팬. 의존성이나 라우터의 무관한 부분이 만든 계측이 여기서 제거됩니다.
    • 허용 목록에 없는 속성. 마스킹이 아니라 제거이므로 값의 흔적도, 길이 정보도 남지 않습니다.
    • 라우터 자신의 두 이벤트가 아닌 모든 스팬 이벤트.

세 번째 규칙이 일반 로그를 막아 줍니다. 계측된 스팬 안에서 발생한 tracing 로그 레코드는 포맷된 로그 한 줄을 message 속성에 담은 스팬 이벤트가 되고, 그 줄에는 프로바이더의 오류 본문이 들어 있을 수 있습니다. 경계에서 이름으로 걸러 버리는 것이 이를 막는 장치입니다.

자유 형식인 OpenTelemetry 상태 메시지도 의도적으로 허용 목록 밖입니다. 라우터는 이 값을 설정하지 않으며, 경계에 도달한 오류 상태 설명은 비워집니다.

소스 수준 테스트가 분류 체계 모듈과 허용 목록이 어긋나지 않도록 검사하므로, 허용 목록에 추가하지 않고 필드를 늘리면 빌드가 실패합니다. 메시지 내용이나 자격 증명처럼 보이는 이름을 필드에 붙여도 마찬가지입니다.

W3C Trace Context 상호운용

인바운드 요청에 유효한 traceparent가 있으면, 내보내는 router.request 스팬은 그 컨텍스트를 부모로 삼습니다. 라우터는 별도 트레이스를 새로 시작하지 않고 상위 트레이스에 합류하며, 자체 클라이언트를 계측한 호출자는 라우터 스팬을 자기 스팬 아래에 중첩된 형태로 보게 됩니다.

백엔드로 나가는 traceparent 주입은 내보내기의 영향을 받지 않습니다. tracing.otlp를 켰든 껐든 라우터는 예전과 같은 헤더를 같은 값으로 씁니다.

형식이 잘못된 인바운드 헤더는 스팬을 트레이스 루트로 남깁니다. 라우터의 기존 트레이스 컨텍스트 검증이 이미 그런 헤더를 거부하고 로그에 남기므로, 내보내기 경로는 요청마다 같은 경고를 두 번 내지 않고 조용히 지나갑니다.

W3C 전파기 자체도 내보내기가 설치될 때만 설치됩니다. 그 전에는 OpenTelemetry 전역 전파기가 SDK의 no-op이며, 그래서 내보내기를 쓰지 않는 라우터에는 OpenTelemetry 쪽 동작이라 할 것이 사실상 없습니다.

껐을 때의 비용

요청 경로는 relaxed 원자적 로드 한 번만 확인하고, 내보내기가 활성 상태가 아니면 요청을 그대로 흘려보냅니다. OpenTelemetry를 위해 헤더를 읽지도, 설정을 복제하지도, tracing 스팬 기계를 건드리지도 않습니다. 모든 기본 빌드와, tracing.otlp.enabled: true를 설정하지 않은 모든 otel 빌드가 이 상태입니다.

스팬 생성자도 같은 플래그로 보호되어 비활성 스팬을 반환하고, 그 결과 뒤따르는 record_* 호출도 no-op이 됩니다. 호출 지점에 별도의 가드가 필요 없습니다.

내보내기 활성 여부와 무관하게 otel 빌드에만 있는 비용이 하나 있습니다. 설정을 읽은 뒤 OpenTelemetry 레이어를 갈아 끼울 수 있도록 서브스크라이버가 리로드 가능한 레이어 슬롯을 갖는데, 리로드 가능한 레이어는 tracing의 정적 콜사이트 관심도 캐시를 쓸 수 없습니다. 그래서 콜사이트 평가마다 락 읽기 한 번이 듭니다. 기본 빌드에는 이 비용이 없습니다.

라이브러리 임베더

직접 tracing 서브스크라이버를 설치하는 임베더(라이브러리 사용법 참고)에게는 리로드 슬롯이 없습니다. 내보내기는 레이어 슬롯이 없다고 보고하고 켜지지 않으므로, 이런 임베더는 tracing-opentelemetry 레이어를 직접 추가해야 합니다.

문제 해결

증상 원인과 조치
컬렉터에 스팬이 전혀 오지 않음 otel 피처 없이 빌드된 바이너리입니다. 시작 로그에서 스팬 내보내기가 비활성이라는 경고를 확인하고 --features otel로 다시 빌드하세요.
스팬도 없고 시작 경고도 없음 tracing.otlp.enabledfalse이거나 tracing.otlp 섹션이 없습니다.
스팬이 조금만 오고 대부분의 트레이스가 없음 sampling.ratio가 기본값 0.05입니다. 값을 올리거나, 샘플링된 인바운드 traceparent를 실어 요청하세요.
protocol: http에서 익스포터를 만들지 못했다는 시작 로그 endpoint/v1/traces 경로가 없습니다. http에서 호스트만 적으면 검증이 거부하고, 경로가 틀린 경우에는 내보내기 시점에 실패합니다.
gRPC에서 인증서 오류로 실패 protocol: grpc는 번들 webpki 루트만 쓰고 OS 신뢰 저장소를 보지 않습니다. 사설 CA 컬렉터 인증서는 검증되지 않습니다. 컬렉터 앞에서 TLS를 종단하거나 공개 루트로 이어지는 인증서를 쓰세요.
401 또는 403으로 실패 인증 헤더의 환경 변수가 설정돼 있지 않습니다. 변수 이름이 담긴 시작 경고를 확인하세요. 치환되지 않은 ${VAR}는 문자열 그대로 전송됩니다. 셸이 아니라 라우터 프로세스의 환경에 변수가 있는지 확인하세요.
스팬은 오는데 속성이 빠져 있음 허용 목록에 없는 속성이라 경계에서 제거된 것입니다. 의도된 동작이며 프라이버시 절을 참고하세요.
종료 시점에 스팬이 끊김 큐에 스팬이 남은 채로 shutdown_timeout이 만료된 것이고, 남은 스팬은 의도적으로 버립니다. 컬렉터가 마지막 배치를 늘 늦게 받는다면 값을 올리세요.
로그 줄이 스팬 이벤트로 보이지 않음 라우터 자신의 두 이벤트만 내보냅니다. 일반 로그 레코드는 경계에서 버려지므로 로그 출력에서 확인하세요.

함께 보기