계측

Sophonz Python SDK가 자동으로 캡처하는 것과 제어하는 방법 — 계측 로딩, 로그와 예외 캡처, 마스킹을 포함한 고급 네트워크 캡처, baggage 승격, 사용자 정의 스팬.

SDK는 프레임워크, HTTP 클라이언트, 데이터베이스용 계측을 자체적으로 포함하지 않습니다. 설치된 공식 OpenTelemetry 계측 패키지를 로드해 설정하고, 그 계측에 없는 것을 더합니다: 모든 스팬의 baggage와 요청 속성, 로그 전송, 예외 캡처, 마스킹을 거친 본문 캡처, 실패한 스팬의 에러 로그.

계측이 로드되는 방식

init()과 opentelemetry-instrument는 모두 opentelemetry_instrumentor 엔트리 포인트에 등록된 패키지를 찾아 각각의 instrument()를 호출합니다. 패키지를 설치하는 것이 곧 계측을 켜는 것입니다.

Extra계측만드는 것
flaskflaskFlask 라우트의 서버 스팬
fastapifastapi서버 스팬과 http receive / http send 자식 스팬
djangodjangoDjango 뷰의 서버 스팬
requestsrequests클라이언트 스팬. 나가는 요청에 traceparent와 baggage를 넣습니다
httpxhttpx동기·비동기 클라이언트 스팬. traceparent와 baggage를 넣습니다
psycopgpsycopgpsycopg 3의 쿼리마다 클라이언트 스팬
psycopg2psycopg2psycopg2의 쿼리마다 클라이언트 스팬
sqlalchemysqlalchemyinit() 이후 만든 엔진의 쿼리마다 클라이언트 스팬
loggingloggingLogRecord의 로그 상관관계 필드(로그 전송은 이 계측 없이도 SDK가 합니다)

extra에 포함된 계측이 이 SDK와 함께 테스트된 계측입니다. opentelemetry-bootstrap -a install은 환경에서 찾은 지원 라이브러리마다 계측을 설치하며, 이렇게 설치한 계측도 로드됩니다.

다음 경우에는 계측을 건너뛰고 디버그 로그를 남깁니다.

  • 계측 대상 라이브러리가 설치되어 있지 않거나, 버전이 계측이 지원하는 범위를 벗어난 경우
  • 이 프로세스에서 이미 계측된 경우
  • 설정으로 비활성화한 경우(아래 참고)

계측별 결과를 보려면 SOPHONZ_DEBUG=true를 설정하고, init()이 실행되기 전에 logging.basicConfig() 등으로 로그 핸들러를 설정하세요. SDK가 시작할 때 Instrumented <name> 또는 Skipping instrumentation <name>: <reason>을 남깁니다.

설정과 비활성화

instrumentations 옵션은 계측 이름을 그 계측의 instrument()에 전달할 키워드 인자에 매핑합니다. {"enabled": False}는 해당 계측을 건너뜁니다.

init(
    service="checkout-api",
    instrumentations={
        "psycopg": {"enable_commenter": True},
        "fastapi": {"exclude_spans": ["receive", "send"]},
        "urllib3": {"enabled": False},
    },
)
  • 키는 엔트리 포인트 이름입니다. 패키지 이름(opentelemetry-instrumentation-psycopg)과 모듈 이름(opentelemetry.instrumentation.psycopg)도 받습니다.
  • 받을 수 있는 인자는 원본 계측의 인자입니다.
  • 한 계측에 대해서는 나중 소스가 우선합니다: 고급 네트워크 캡처 훅, SOPHONZ_PYTHON_SQLCOMMENTER, 그리고 instrumentations 항목 순입니다.

opentelemetry-instrument에서는 같은 맵을 JSON으로 전달합니다.

export SOPHONZ_PYTHON_INSTRUMENTATIONS='{"psycopg": {"enable_commenter": true}, "urllib3": {"enabled": false}}'

OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=requests,urllib3은 두 방식 모두에서 이름으로 계측을 건너뛰며, *는 모두 건너뜁니다.

엔트리 포인트로 등록되지 않은 계측기는 인스턴스로 전달할 수 있습니다. SDK는 설치된 계측을 로드한 뒤 인자 없이 instrument()를 호출합니다.

from myteam.telemetry import CacheInstrumentor
 
init(service="checkout-api", additional_instrumentations=[CacheInstrumentor()])

SDK 자신의 전송 요청

SDK는 자신의 전송 URL을 OTEL_PYTHON_REQUESTS_EXCLUDED_URLS, OTEL_PYTHON_URLLIB_EXCLUDED_URLS, OTEL_PYTHON_URLLIB3_EXCLUDED_URLS, OTEL_PYTHON_HTTPX_EXCLUDED_URLS에 추가합니다. 이 변수나 OTEL_PYTHON_EXCLUDED_URLS에 이미 설정한 값은 유지됩니다. 전송 요청이 나가는 HTTP 스팬으로 나타나는 일은 없습니다.

로그

console_capture(기본값 켜짐)가 켜져 있으면 SDK가 루트 로거에 OTLP 핸들러를 붙입니다.

  • 로깅 설정이 루트 로거로 통과시키는 레벨의 레코드가 전송됩니다. Python 기본값은 WARNING이므로, info 로그를 보내려면 INFO로 설정하세요.
  • 스팬 안에서 남긴 레코드는 그 스팬의 트레이스 ID와 스팬 ID를 가집니다.
  • extra={...} 필드는 로그 속성이 됩니다.
  • print()와 stdout에 직접 쓰는 내용은 캡처하지 않습니다.
import logging
 
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("checkout")
 
logger.info("order placed", extra={"order_id": 42})

logging.basicConfig, logging.config.dictConfig(Django의 LOGGING 포함), logging.config.fileConfig는 init() 전후 언제 실행해도 됩니다. 어느 쪽이든 OTLP 핸들러는 루트 로거에 남습니다.

opentelemetry-instrumentation-logging이 설치되어 있으면, 직접 설정하지 않은 한 SDK가 OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION=false를 설정해 레코드가 두 번 전송되지 않게 합니다.

예외

예외는 세 경로로 트레이스에 들어오며, 각각 한 번씩만 기록됩니다.

출처기록 주체exception.mechanism
Flask, FastAPI, Django 라우트에서 빠져나간 예외프레임워크 계측. SDK의 에러 핸들러는 계측이 기록하지 않았을 때만 기록합니다에러 핸들러를 등록했다면 flask, asgi, django
메인 스레드의 처리되지 않은 예외sys.excepthooksys.excepthook
다른 스레드의 처리되지 않은 예외threading.excepthookthreading.excepthook
asyncio 루프의 예외 핸들러로 보고된 예외루프 예외 핸들러 훅asyncio
직접 호출record_exception()generic 또는 전달한 mechanism

세 훅은 experimental_exception_capture가 설치하며, init()은 이 옵션을 켭니다. sys.excepthook이나 threading.excepthook에서 기록한 뒤에는 프로세스가 곧 끝날 수 있으므로 flush하고, 원래 설치되어 있던 훅을 호출합니다. 메인 스레드의 KeyboardInterrupt와 다른 스레드의 SystemExit는 무시합니다. asyncio 훅은 표준 이벤트 루프에만 적용됩니다.

record_exception()은 처리한 예외를 exception 이벤트로 기록하고 스팬 상태를 ERROR로 설정합니다.

from sophonz.opentelemetry import record_exception
 
try:
    charge(order)
except PaymentDeclined as exc:
    record_exception(exc, attributes={"order.id": order.id})
    raise

span=을 주면 그 스팬에, 아니면 현재 스팬에 기록합니다. 컨텍스트에 스팬이 없으면 예외 타입 이름으로 스팬을 시작하고 끝내므로 예외가 사라지지 않습니다.

실패한 스팬의 에러 로그

error_log_capture(기본값 켜짐)가 켜져 있으면, ERROR 상태와 exception 이벤트 또는 상태 설명을 가진 채 끝난 스팬마다 그 스팬의 트레이스에 연결된 로그 레코드를 하나 남깁니다.

속성값
app.span.typeerror
exception.type예외 클래스 이름
exception.message메시지, 1024자에서 자름
exception.stacktrace스택 트레이스, 4096자에서 자름
trace.id, span.id, span.name실패한 스팬

레코드 본문은 스택 트레이스입니다. Node.js·브라우저 SDK와 같은 형태이므로 모든 계층의 에러를 같은 방식으로 조회할 수 있습니다. console_capture가 꺼져 있어도 만들어지며, 로그를 비활성화하면 만들어지지 않습니다.

고급 네트워크 캡처

advanced_network_capture=True 또는 SOPHONZ_PYTHON_ADVANCED_NETWORK_CAPTURE=true는 HTTP 헤더와 본문을 스팬에 추가합니다.

헤더

직접 설정하지 않았다면 SDK가 다음 변수를 설정합니다.

변수값
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST.*
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE.*
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST.*
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE.*
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDSauthorization,proxy-authorization,cookie,set-cookie,x-api-key

계측은 헤더를 http.request.header.<name>과 http.response.header.<name>으로 기록하며, 이름은 소문자로 바꾸고 -를 _로 바꿉니다(http.request.header.user_agent). requests와 urllib 클라이언트 스팬에는 SDK 훅이 각 헤더를 원래 이름으로도 기록합니다(http.request.header.user-agent).

authorization, proxy-authorization, cookie, set-cookie, x-api-key의 값은 [REDACTED]로 기록됩니다. 더 가리려면 이 다섯 개를 포함해 sanitize 변수를 직접 설정하세요. 직접 설정한 값은 기본 목록을 대체합니다.

export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS="authorization,proxy-authorization,cookie,set-cookie,x-api-key,x-session-token"

캡처할 헤더를 줄이려면 capture 변수를 .* 대신 쉼표로 구분한 이름이나 정규식 목록으로 설정하세요.

본문

계측요청 본문응답 본문
requests예응답을 읽은 경우만(stream=True로 본문을 아직 읽지 않았다면 아니오)
urllib예아니오. 읽으면 코드가 읽기 전에 스트림을 소비합니다
fastapi, starlette예, http receive 스팬에예, http send 스팬에
flask, django, httpx아니오아니오

본문은 http.request.body와 http.response.body로 기록됩니다. FastAPI에서 exclude_spans로 receive와 send 스팬을 제외하면 본문 캡처도 멈춥니다.

마스킹 규칙

  • 본문은 16KiB(16,384바이트)에서 자르고 UTF-8로 디코딩합니다.

  • 본문 어디에든 다음 키워드가 대소문자 구분 없이 들어 있으면 본문 전체를 [Filtered]로 바꿉니다.

    password, passwd, secret, api_key, apikey, auth, credentials, mysql_pwd, privatekey, private_key, token, session, csrftoken, sessionid, x_csrftoken, set_cookie, cookie, authorization, x_api_key, aiohttp_session, connect.sid, csrf_token, csrf, _csrf, _csrf_token, phpsessid, _session, symfony, user_session, _xsrf, xsrf-token

  • 부분 문자열 일치이므로 평범한 필드 이름도 걸릴 수 있습니다. {"authorId": 1}은 auth를 포함하므로 [Filtered]로 기록됩니다.

  • 목록은 Node.js SDK와 같으며 바꿀 수 없습니다.

WARNING — 본문에는 여전히 개인정보가 있을 수 있습니다

마스킹은 자격 증명처럼 보이는 키워드만 찾습니다. 이름, 이메일 주소 같은 개인정보는 그대로 통과합니다. 요청·응답 내용을 저장해도 되는 곳에서만 본문 캡처를 켜세요.

Baggage와 session.id

SDK는 시작하는 모든 스팬에 대해, 스팬 컨텍스트의 W3C baggage 항목을 baggage.<key>로 스팬에 복사합니다. 스팬 프로세서에서 처리하므로 서버 스팬, 데이터베이스 스팬, HTTP 클라이언트 스팬, 직접 만든 스팬 모두에 붙습니다.

promote_baggage_keys에 있는 키는 원래 이름으로도 복사됩니다. 기본값은 ["session.id"]입니다. Sophonz 브라우저 SDK가 baggage 헤더로 세션 ID를 보내므로, 그 요청의 모든 서버 스팬이 baggage.session.id와 session.id를 모두 가집니다.

init(service="checkout-api", promote_baggage_keys=["session.id", "tenant.id"])

목록을 전달하면 기본값을 대체하므로, 키를 추가할 때는 session.id도 포함하세요.

Python에서 설정한 baggage는 그 아래에서 시작한 스팬에 복사되고, requests와 httpx 계측이 다운스트림 서비스로 보냅니다.

from sophonz.opentelemetry import baggage, context
 
token = context.attach(baggage.set_baggage("tenant.id", "acme"))
try:
    sync_tenant()  # 여기서 만든 스팬은 baggage.tenant.id를 가집니다
finally:
    context.detach(token)

CAUTION — Baggage는 서비스 밖으로 나갑니다

계측된 HTTP 클라이언트로 보내는 모든 요청은 서드파티로 가는 요청까지 baggage 헤더를 가집니다. baggage에 비밀 값이나 개인정보를 넣지 마세요.

사용자 정의 스팬

sophonz.opentelemetry는 OpenTelemetry API를 다시 내보내므로 사용자 정의 스팬에 별도 import가 필요 없습니다. 요청 안에서 시작한 스팬은 요청 스팬의 자식이 되어 함께 전송됩니다.

from sophonz.opentelemetry import SpanKind, Status, StatusCode, trace
 
tracer = trace.get_tracer("checkout")
 
 
def price_cart(cart):
    with tracer.start_as_current_span("price-cart", attributes={"cart.size": len(cart.items)}) as span:
        total = sum(item.price for item in cart.items)
        span.set_attribute("cart.total", total)
        if total <= 0:
            span.set_status(Status(StatusCode.ERROR, "empty cart"))
        return total
 
 
def call_tax_service(order):
    with tracer.start_as_current_span("tax.calculate", kind=SpanKind.CLIENT):
        ...

요청 전체에 속성 붙이기

span.set_attribute()는 스팬 하나를 바꿉니다. set_trace_attributes()는 이 프로세스에서 현재 요청의 모든 스팬에 속성을 설정합니다: 서버 스팬, 이미 열려 있는 스팬, 그리고 같은 로컬 루트 아래에서 나중에 시작하는 모든 스팬. 스팬에 이미 있는 속성은 덮어쓰지 않습니다.

from sophonz.opentelemetry import set_trace_attributes
 
 
@app.get("/api/orders/<order_id>")
def get_order(order_id):
    user = current_user()
    set_trace_attributes({"enduser.id": user.id, "tenant.id": user.tenant_id})
    return load_order(order_id)  # 데이터베이스 스팬이 enduser.id와 tenant.id를 가집니다

로컬 루트는 이 프로세스에서 그 요청을 위해 처음 시작한 스팬이므로, 업스트림 서비스의 스팬은 바뀌지 않습니다. 스팬 밖에서 호출하면 아무 일도 하지 않습니다. 프레임워크 헬퍼도 요청 속성을 이 함수로 붙입니다.

메트릭

미터 프로바이더도 설정되므로 OpenTelemetry 메트릭 API를 평소처럼 쓸 수 있습니다.

from sophonz.opentelemetry import metrics
 
orders = metrics.get_meter("checkout").create_counter("orders.placed")
orders.add(1, {"payment.method": "card"})

NOTE — 호스팅 엔드포인트의 메트릭

https://in.sophonz.ai는 현재 트레이스와 로그를 받습니다. /v1/metrics 경로는 404를 응답하고, 메트릭 익스포터는 주기마다 전송 에러를 남깁니다. 이 엔드포인트로 보낼 때는 OTEL_METRICS_EXPORTER=none 또는 disable_metrics=True를 설정하세요. 배포를 참고하세요.

다음 단계

TIP — 이제 시작입니다

이제 앱을 배포하세요. Sophonz가 실사용자가 겪는 문제를 포착하고, 당신의 앱은 스스로 진화합니다.