프레임워크 가이드
Flask, FastAPI, Django 애플리케이션과 운영 서버에 Sophonz Python SDK 연결하기 — gunicorn 워커, uvicorn과 lifespan, Django 진입점, 미들웨어 순서, 에러 처리.
프레임워크마다 init()을 둘 자리가 정해져 있고, 그 코드가 언제 실행되는지는 서버의 프로세스 모델이 결정합니다. 이 페이지는 Flask, FastAPI, Django 각각에 대해 두 가지를 모두 다룹니다. 구성은 Sophonz 샘플 서버를 따릅니다: SDK를 시작하는 telemetry.py, 이를 가장 먼저 import하는 진입점, 요청 헬퍼를 추가하는 애플리케이션 팩토리.
어디에나 적용되는 규칙
- 프레임워크와 데이터베이스 드라이버를 import하기 전에 SDK를 시작하세요.
init()은 OpenTelemetry 계측을 로드하고, 계측은 프레임워크와 드라이버 모듈을 패치합니다. 그 전에 만들어진 클래스와 연결은 트레이스되지 않거나,instrumentations설정 없이 트레이스됩니다. - 프로세스당 한 번, 요청을 처리하는 프로세스에서 시작하세요. OpenTelemetry는 전역 프로바이더를 한 번만 설정합니다. 같은 프로세스에서
init()을 다시 호출하면False를 반환하고 경고를 남깁니다. - pre-fork 서버에서는 워커마다 시작하세요. 익스포터는 백그라운드 스레드를 쓰는데, 스레드는
fork()후에 살아남지 않습니다.
| 서버 | init()이 실행되는 곳 | 종료 시 flush |
|---|---|---|
gunicorn, --preload 없음 | 각 워커가 WSGI 모듈을 import할 때 | SDK 시그널 핸들러, 이어서 worker_exit |
gunicorn, --preload | fork 전 마스터 | worker_exit (설치 참고) |
| uvicorn, 단일 프로세스 | 서버 프로세스가 앱을 import할 때 | SDK 시그널 핸들러, 이어서 lifespan 종료 |
uvicorn --workers N | 각 워커 프로세스 | SDK 시그널 핸들러, 이어서 lifespan 종료 |
manage.py runserver --noreload | runserver 프로세스 | SDK 시그널 핸들러 |
샘플 서버는 gunicorn을 --preload 없이, uvicorn을 파드당 프로세스 하나로 실행하며, 이 페이지의 나머지도 같은 구성을 전제로 합니다.
Flask
Flask와 데이터베이스 드라이버에 맞는 extra를 설치합니다.
pip install "sophonz-opentelemetry[flask,psycopg]" flask gunicorn "psycopg[binary,pool]"구성
myservice/
telemetry.py # init()
wsgi.py # telemetry를 먼저 import한 뒤 앱을 만듭니다
app.py # create_app(): 헬퍼, 라우트, 에러 핸들러
gunicorn.conf.pytelemetry.py에 SDK 설정을 모아 두면 모든 진입점(WSGI 모듈, CLI, 테스트)이 같은 방식으로 SDK를 시작합니다.
# myservice/telemetry.py
import os
from sophonz.opentelemetry import init
SERVICE_NAME = os.getenv("OTEL_SERVICE_NAME", "checkout-api")
def setup_telemetry() -> bool:
return init(
service=SERVICE_NAME,
api_key=os.getenv("SOPHONZ_API_KEY"),
instrumentations={
"psycopg": {"enable_commenter": True},
},
)WSGI 모듈은 Flask나 psycopg를 import하기 전에 이를 호출합니다.
# myservice/wsgi.py
import logging
from myservice.telemetry import setup_telemetry
setup_telemetry()
logging.basicConfig(level=logging.INFO)
from myservice.app import create_app # noqa: E402
app = create_app()init() 뒤에 logging.basicConfig를 호출해도 됩니다. SDK는 basicConfig, dictConfig, fileConfig를 거쳐도 루트 로거에 OTLP 핸들러를 유지합니다.
애플리케이션 팩토리
# myservice/app.py
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException, InternalServerError
from werkzeug.middleware.proxy_fix import ProxyFix
from sophonz.opentelemetry.flask import setup_flask_error_handler, sophonz_flask_middleware
def create_app() -> Flask:
app = Flask(__name__)
# 리버스 프록시 뒤에서: request.remote_addr, 즉 http.client.ip가
# X-Forwarded-For에서 옵니다.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
sophonz_flask_middleware(app)
setup_flask_error_handler(app)
@app.get("/api/error")
def error():
raise RuntimeError("Intentional error")
@app.errorhandler(InternalServerError)
def internal_error(exc):
original = exc.original_exception or exc
return jsonify(error=str(original)), 500
@app.errorhandler(HTTPException)
def http_error(exc):
return jsonify(error=exc.description), exc.code
return appsophonz_flask_middleware는before_request훅을 등록해 요청의 모든 스팬에 baggage,http.client.ip,http.user_agent,attributes를 붙입니다. 이 앱 객체가 아직 계측되지 않았다면 계측도 합니다.setup_flask_error_handler는 Flask의got_request_exception시그널을 구독합니다. 이 시그널은errorhandler보다 먼저 발생하므로, 직접 만든 500 핸들러가 있어도 예외가 트레이스에서 사라지지 않습니다.abort(404)와 그 밖의HTTPException은 시그널을 발생시키지 않으므로 기록되지 않습니다.
gunicorn
# gunicorn.conf.py
import os
bind = f"0.0.0.0:{os.getenv('PORT', '8000')}"
workers = int(os.getenv("WEB_CONCURRENCY", "2"))
worker_class = "gthread"
threads = int(os.getenv("GUNICORN_THREADS", "4"))
timeout = 30
graceful_timeout = 20
# preload_app 없음: 각 워커가 fork된 뒤 wsgi.py를 import하고 SDK를 시작합니다.
def worker_exit(server, worker):
# 여기서 import합니다: 마스터 프로세스는 SDK를 시작하지 않습니다.
from sophonz.opentelemetry import shutdown
shutdown()gunicorn -c gunicorn.conf.py myservice.wsgi:appSIGTERM을 받으면 각 워커에서 SDK 핸들러가 flush한 뒤 gunicorn의 정상 종료로 넘기고, worker_exit가 요청을 마무리하는 동안 기록된 텔레메트리를 flush합니다.
Flask에서 붙는 SQL 주석
드라이버에서 주석을 켜면 Flask 계측이 SQLCommenter 주석에 framework, controller, route 태그를 더합니다. 그러면 주석에 트레이스 컨텍스트 외의 값이 들어가고, 라우트마다 주석 텍스트가 달라집니다. traceparent만 남기려면 Flask 쪽에서 끄세요.
instrumentations={
"psycopg": {"enable_commenter": True},
"flask": {"enable_commenter": False},
}드라이버 옵션은 데이터베이스 트레이싱을 참고하세요.
FastAPI
pip install "sophonz-opentelemetry[fastapi,psycopg]" fastapi uvicorn "psycopg[binary,pool]"진입점
# myservice/main.py
import logging
from myservice.telemetry import setup_telemetry
setup_telemetry()
logging.basicConfig(level=logging.INFO)
from myservice.app import create_app # noqa: E402
app = create_app()FastAPI 객체는 init() 뒤에 만드세요. FastAPI 계측은 자신이 로드된 뒤 생성되는 앱에 instrumentations["fastapi"] 설정과 고급 네트워크 캡처 훅을 적용합니다. 그보다 먼저 만든 앱도 sophonz_fastapi_middleware가 계측하지만, 계측의 기본값으로 계측됩니다.
애플리케이션, 미들웨어, lifespan
# myservice/app.py
import contextlib
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from psycopg_pool import AsyncConnectionPool
from sophonz.opentelemetry.fastapi import setup_fastapi_error_handler, sophonz_fastapi_middleware
def create_app() -> FastAPI:
pool = AsyncConnectionPool("postgresql://app:app@localhost:5432/app", open=False)
@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI):
await pool.open()
try:
yield
finally:
await pool.close()
app = FastAPI(lifespan=lifespan)
setup_fastapi_error_handler(app)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["traceparent", "tracestate", "baggage", "content-type"],
)
sophonz_fastapi_middleware(app)
@app.get("/api/error")
async def error():
raise RuntimeError("Intentional error")
@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception):
headers = {}
if origin := request.headers.get("origin"):
headers = {"Access-Control-Allow-Origin": origin, "Vary": "Origin"}
return JSONResponse({"error": str(exc)}, status_code=500, headers=headers)
return app미들웨어 순서. Starlette의 add_middleware는 새로 추가한 미들웨어를 가장 바깥에 둡니다. Sophonz 헬퍼 두 개는 반대로 자신의 미들웨어를 가장 안쪽에 덧붙이므로, 등록 순서와 상관없이 항상 OpenTelemetry 서버 스팬 안에서 실행됩니다. 위 코드의 스택은 바깥에서 안쪽 순으로 다음과 같습니다.
- OpenTelemetry 서버 미들웨어(계측이 추가)
CORSMiddlewareSophonzExceptionMiddleware(setup_fastapi_error_handler가 추가)SophonzASGIMiddleware(sophonz_fastapi_middleware가 추가)
두 헬퍼는 앱이 요청을 받기 시작하기 전에 호출하세요. 그 뒤에 미들웨어를 추가하면 RuntimeError가 발생합니다.
에러. SophonzExceptionMiddleware는 라우트에서 빠져나간 예외를 기록한 뒤 다시 던집니다. HTTPException과, 예외 핸들러가 응답으로 바꾼 예외는 기록하지 않습니다. Starlette는 처리되지 않은 예외에 ServerErrorMiddleware에서 응답하는데, 이 미들웨어는 CORSMiddleware를 포함한 모든 사용자 미들웨어 바깥에 있습니다. 그러면 브라우저는 500 대신 CORS 에러를 보게 되므로, 위 핸들러가 CORS 헤더를 직접 붙입니다.
Lifespan. SophonzASGIMiddleware는 애플리케이션이 lifespan 종료 완료를 알린 뒤 텔레메트리를 flush합니다. uvicorn이 요청을 마무리하는 동안 기록된 스팬도 프로세스가 끝나기 전에 내보내집니다.
비동기 데이터베이스 접근
트레이스 컨텍스트는 await와 asyncio.gather를 따라갑니다. 한 요청 안에서 동시에 시작한 두 쿼리는 모두 그 요청 스팬의 자식입니다.
import asyncio
@app.get("/api/users/{user_id}")
async def get_user(user_id: int):
async def find_user():
async with pool.connection() as conn:
cur = await conn.execute("SELECT id, email FROM users WHERE id = %s", (user_id,))
return await cur.fetchone()
async def find_posts():
async with pool.connection() as conn:
cur = await conn.execute("SELECT id, title FROM posts WHERE author_id = %s", (user_id,))
return await cur.fetchall()
user, posts = await asyncio.gather(find_user(), find_posts())
return {"user": user, "posts": posts}uvicorn
uvicorn myservice.main:app --host 0.0.0.0 --port 8000 \
--loop asyncio --proxy-headers --forwarded-allow-ips "*" \
--timeout-graceful-shutdown 20--reload없이. 리로더는 파일이 바뀔 때마다 요청을 처리하는 프로세스를, 그리고 SDK를 새로 시작합니다. 샘플 서버는 리로더 없이 실행합니다.--loop asyncio.experimental_exception_capture는 표준 asyncio 이벤트 루프의 예외 핸들러에 훅을 겁니다. uvloop는 해당되지 않습니다.- **
--proxy-headers**를 주면 신뢰하는 프록시가 설정한X-Forwarded-For에서 클라이언트 주소, 즉http.client.ip를 가져옵니다. - **
--workers N**은 프로세스 N개를 띄우고, 각 프로세스가 앱을 import해 자신의 SDK를 시작합니다. 컨테이너에서는 샘플 서버처럼 파드당 프로세스 하나로 두고 레플리카 수로 확장합니다.
요청당 스팬 줄이기
ASGI 계측은 요청의 메시지마다 http receive와 http send 자식 스팬을 기록합니다. 서버 스팬만 남기려면 다음과 같이 설정합니다.
instrumentations={"fastapi": {"exclude_spans": ["receive", "send"]}}CAUTION — 본문은 그 스팬에 캡처됩니다
advanced_network_capture를 켜면 FastAPI의 요청·응답 본문은 http receive와 http send 스팬에 기록됩니다. 이 스팬을 제외하면 본문 캡처도 꺼집니다. 헤더는 계속 서버 스팬에 캡처됩니다.
Django
pip install "sophonz-opentelemetry[django,psycopg]" django gunicorn "psycopg[binary]"진입점
Django 계측은 로드될 때 설정을 읽으므로, init() 전에 DJANGO_SETTINGS_MODULE이 설정되어 있어야 합니다. SDK 시작 코드를 한 모듈에 두고 요청을 처리하는 모든 진입점에서 호출하세요.
# mysite/telemetry.py
import os
from sophonz.opentelemetry import init, is_initialized
def setup_telemetry() -> bool:
if is_initialized():
return False
return init(
service=os.getenv("OTEL_SERVICE_NAME", "mysite"),
api_key=os.getenv("SOPHONZ_API_KEY"),
instrumentations={"psycopg": {"enable_commenter": True}},
)# mysite/wsgi.py — gunicorn mysite.wsgi
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
from mysite.telemetry import setup_telemetry # noqa: E402
setup_telemetry()
from django.core.wsgi import get_wsgi_application # noqa: E402
application = get_wsgi_application()# mysite/asgi.py — uvicorn mysite.asgi:application
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
from mysite.telemetry import setup_telemetry # noqa: E402
setup_telemetry()
from django.core.asgi import get_asgi_application # noqa: E402
application = get_asgi_application()# manage.py
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
# 요청을 처리하는 것은 개발 서버뿐입니다. migrate 등 다른 명령은
# SDK 없이 실행됩니다.
if len(sys.argv) > 1 and sys.argv[1] == "runserver":
from mysite.telemetry import setup_telemetry
setup_telemetry()
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()개발 서버는 python manage.py runserver --noreload로 실행해, 요청을 처리하는 프로세스에서 SDK가 시작되게 하세요. runserver는 WSGI_APPLICATION도 로드하는데, is_initialized() 확인 덕분에 이 두 번째 호출은 경고 없이 넘어갑니다.
gunicorn은 Flask와 같은 gunicorn.conf.py를 쓰고, gunicorn -c gunicorn.conf.py mysite.wsgi로 실행합니다.
설정
# mysite/settings.py (발췌)
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
# ... REMOTE_ADDR을 바꾸는 미들웨어는 이 줄보다 위에 둡니다
"sophonz.opentelemetry.django.SophonzMiddleware",
]
SOPHONZ_MIDDLEWARE = {
"attributes": "mysite.telemetry.request_attributes", # 또는 dict
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
}# mysite/telemetry.py (이어서)
def request_attributes(request):
user = getattr(request, "user", None)
if user is None or not user.is_authenticated:
return {}
return {"enduser.id": str(user.pk)}attributes 함수가 예외를 던지면 그 요청에는 속성이 하나도 추가되지 않습니다. 요청 자체는 계속 처리됩니다.
- Django 계측은 로드될 때
MIDDLEWARE맨 위에 자신의 미들웨어를 삽입하고, 이 미들웨어가 서버 스팬을 시작합니다. SophonzMiddleware는 요청이 도달한 시점의REMOTE_ADDR과 user agent를 읽습니다.X-Forwarded-For로REMOTE_ADDR을 설정하는 미들웨어는 그보다 앞에 있어야 합니다.- Django는
process_exception을 목록 아래에서 위로 호출합니다.SophonzMiddleware를 마지막에 두면 다른 미들웨어가 예외를 응답으로 바꾸기 전에 뷰의 예외를 기록할 수 있습니다. LOGGING은dictConfig로 적용되며, SDK의 OTLP 핸들러는 루트 로거에 그대로 남습니다.
에러
SophonzMiddleware.process_exception은 뷰에서 발생한 예외를 기록합니다. Django가 4xx 응답으로 바꾸는 Http404, PermissionDenied, BadRequest, SuspiciousOperation은 제외합니다.
NOTE — Django 계측은 모든 뷰 예외를 기록합니다
Django 계측의 자체 미들웨어는 뷰가 던진 예외를 Http404까지 포함해 모두 서버 스팬에 기록하고 스팬 상태를 ERROR로 설정합니다. error_log_capture가 켜져 있으면 이 스팬도 에러 로그 레코드를 만듭니다. 이런 노이즈가 문제라면 Http404를 던지는 대신 404 응답을 반환하세요.
ORM
Django의 PostgreSQL 백엔드는 psycopg를 사용하므로, psycopg 계측이 ORM 쿼리마다 클라이언트 스팬을 만들고 SQL 주석을 붙입니다. 주석은 드라이버에서만 켜세요. Django 계측의 is_sql_commentor_enabled까지 켜면 주석이 두 번 붙습니다. 데이터베이스 트레이싱을 참고하세요.
uvicorn에서 실행할 때
Django의 ASGI 핸들러는 lifespan 이벤트를 구현하지 않으므로, uvicorn이 요청을 마무리한 뒤 flush해 줄 곳이 없습니다. SDK 시그널 핸들러가 시그널을 받을 때 flush하지만, 마무리 중에 끝난 스팬은 마지막 전송에서 빠질 수 있습니다. Django는 worker_exit를 쓰는 gunicorn으로 실행하거나, OTEL_BSP_SCHEDULE_DELAY(기본 5000ms)를 낮추세요.
스크립트와 일회성 작업
명령줄 작업에는 시그널을 넘겨받을 서버가 없습니다. SDK를 먼저 시작하고, 작업이 끝나면 shutdown()을 호출해 인터프리터가 끝나기 전에 마지막 배치를 내보내세요.
import os
from sophonz.opentelemetry import init, shutdown, trace
init(service="nightly-sync", api_key=os.getenv("SOPHONZ_API_KEY"))
tracer = trace.get_tracer("nightly-sync")
try:
with tracer.start_as_current_span("sync-inventory"):
run_sync()
finally:
shutdown()다음 단계
TIP — 이제 시작입니다
이제 앱을 배포하세요. Sophonz가 실사용자가 겪는 문제를 포착하고, 당신의 앱은 스스로 진화합니다.