데이터베이스 트레이싱
psycopg, psycopg2, SQLAlchemy, Django ORM으로 Python의 PostgreSQL 쿼리를 트레이스하고, SQLCommenter로 트레이스를 데이터베이스까지 이어 pg_tracing이 같은 트레이스에 서버 측 스팬을 더하게 하기.
데이터베이스 트레이싱은 두 부분으로 나뉩니다. 드라이버 계측은 쿼리마다 클라이언트 스팬을 기록해 애플리케이션이 얼마나 기다렸는지 보여 줍니다. SQLCommenter는 각 SQL 문에 W3C traceparent를 덧붙여, PostgreSQL의 pg_tracing 확장이 그동안 데이터베이스가 한 일을 같은 트레이스의 스팬으로 기록하게 합니다. 앞부분은 extra만 설치하면 되고, 뒷부분은 기본값이 꺼져 있어 직접 켜야 합니다.
extra 고르기
| 스택 | 설치 | 쿼리를 트레이스하는 계측 |
|---|---|---|
| psycopg 3 | sophonz-opentelemetry[psycopg] | psycopg |
| psycopg2 | sophonz-opentelemetry[psycopg2] | psycopg2 |
| psycopg 3 위의 SQLAlchemy | sophonz-opentelemetry[sqlalchemy,psycopg] | sqlalchemy 또는 psycopg(하나만, 아래 참고) |
| Django ORM | sophonz-opentelemetry[django,psycopg] | psycopg |
드라이버를 import하기 전, 그리고 연결·풀·엔진을 만들기 전에 init()을 호출하세요. 그 전에 연 연결은 계측되지 않습니다.
클라이언트 스팬
extra를 설치하면 모든 SQL 문이 쿼리를 실행할 때의 현재 스팬 아래에 CLIENT 스팬을 만듭니다. 스팬 이름은 SQL 연산(SELECT)이고, SQL 문 텍스트를 파라미터 자리 표시자와 함께 db.statement로 가집니다.
설정은 필요 없습니다. 다만 클라이언트 스팬만으로는 PostgreSQL 안에서 시간이 어디에 쓰였는지, 즉 계획 수립, 락 대기, 백만 행을 읽은 스캔 같은 것은 보이지 않습니다. 이것을 주석과 pg_tracing이 채웁니다.
SQLCommenter 켜기
회선에 가장 가까운 계측에 enable_commenter를 전달하세요.
import os
from sophonz.opentelemetry import init
init(
service="checkout-api",
api_key=os.getenv("SOPHONZ_API_KEY"),
instrumentations={
"psycopg": {
"enable_commenter": True,
# 주석에 트레이스 컨텍스트만 남깁니다
"commenter_options": {
"db_driver": False,
"dbapi_threadsafety": False,
"dbapi_level": False,
"libpq_version": False,
"driver_paramstyle": False,
},
},
},
)그러면 PostgreSQL은 다음을 받습니다.
SELECT id FROM users WHERE id = $1 /*traceparent='00-af36bce59ed899006154f6fdbe8f1155-a0fc80d07613b887-03'*/commenter_options가 없으면 드라이버 태그도 함께 들어갑니다.
SELECT id FROM users WHERE id = $1 /*db_driver='psycopg%3A3.3.5',dbapi_level='2.0',dbapi_threadsafety=2,driver_paramstyle='pyformat',libpq_version=180006,traceparent='00-fdcd384a08bf56936f73209d94c9bc47-a88d94702e452911-03'*/pg_tracing은 traceparent만 읽습니다. Sophonz 샘플 서버는 나머지 태그를 꺼서 주석을 짧게, 그리고 드라이버 버전과 무관하게 같게 유지합니다.
계층별 옵션
| 계측 | 켜기 | commenter_options로 끌 수 있는 태그 |
|---|---|---|
psycopg | enable_commenter | db_driver, dbapi_threadsafety, dbapi_level, libpq_version, driver_paramstyle, opentelemetry_values |
psycopg2 | enable_commenter | psycopg와 같음 |
sqlalchemy | enable_commenter | db_driver, db_framework, opentelemetry_values |
django | is_sql_commentor_enabled | Django 설정으로 구성 |
flask | enable_commenter(기본값 켜짐) | framework, controller, route |
opentelemetry_values가 곧 traceparent입니다. 켜 두세요.
Flask 행은 성격이 다릅니다. Flask 계측은 스스로 주석을 달지 않고, 드라이버가 쓰는 주석에 framework, controller, route 태그를 더합니다. 이 태그를 빼려면 "flask": {"enable_commenter": False}를 전달하세요.
환경 변수로 켜기
SOPHONZ_PYTHON_SQLCOMMENTER=true는 psycopg와 psycopg2에 enable_commenter를 설정하며, opentelemetry-instrument에서 주석을 켜는 방법입니다. commenter_options는 설정하지 않으므로 드라이버 태그가 포함됩니다. instrumentations 항목이나 SOPHONZ_PYTHON_INSTRUMENTATIONS가 이를 덮어씁니다.
export SOPHONZ_PYTHON_INSTRUMENTATIONS='{"psycopg": {"enable_commenter": true, "commenter_options": {"db_driver": false, "dbapi_threadsafety": false, "dbapi_level": false, "libpq_version": false, "driver_paramstyle": false}}}'주석은 한 계층에서만
주석을 켠 계층마다 자신의 주석을 덧붙입니다. psycopg, psycopg2, Django 애플리케이션은 드라이버에서 켜세요. SQLAlchemy는 SQLAlchemy 또는 그 아래 드라이버 중 한쪽에서만 켜세요.
psycopg 3
프로세스마다 연결 풀을 두면 계측이 그대로 동작합니다. --preload 없는 gunicorn에서는 각 워커가 init() 뒤에 풀을 만듭니다.
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
pool = ConnectionPool(
os.environ["DATABASE_URL"],
min_size=1,
max_size=5,
kwargs={"autocommit": True, "row_factory": dict_row},
open=False,
)
def list_users():
if pool.closed:
pool.open(wait=False)
with pool.connection() as conn:
return conn.execute("SELECT id, email, name FROM users ORDER BY id").fetchall()트랜잭션 안의 SQL 문은 요청 스팬 아래에 순서대로 나타납니다.
with pool.connection() as conn, conn.transaction():
for post in posts:
conn.execute(
"INSERT INTO posts (author_id, title, body) VALUES (%s, %s, %s)",
(author_id, post["title"], post["body"]),
)AsyncConnectionPool은 애플리케이션 lifespan에서 풀을 열고 async with pool.connection()으로 사용하세요. 트레이스 컨텍스트는 await와 asyncio.gather를 지나도 유지되므로, 동시에 실행한 쿼리도 이를 시작한 요청의 자식이 됩니다. FastAPI 가이드를 참고하세요.
psycopg2
init(
service="reports",
instrumentations={"psycopg2": {"enable_commenter": True}},
)
import psycopg2 # noqa: E402
conn = psycopg2.connect(os.environ["DATABASE_URL"])
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE id = %s", (1,))계측은 psycopg2.connect를 감쌉니다. psycopg2는 init() 뒤에 import하고, 그 전에 connect를 모듈 네임스페이스로 가져오지 마세요(from psycopg2 import connect). 감싸지 않은 함수가 그대로 남습니다.
SQLAlchemy
sqlalchemy 계측은 create_engine()을 감싸 그 함수가 만드는 엔진을 트레이스합니다. create_engine은 init() 뒤에 import하세요. 그보다 먼저 실행된 from sqlalchemy import create_engine은 감싸지 않은 함수를 붙들고 있어, 그 엔진은 쿼리 스팬을 만들지 않습니다. 스팬 이름은 연산과 데이터베이스(SELECT app)이고, 연결을 열 때 connect 스팬이 생깁니다.
init(
service="checkout-api",
instrumentations={
"sqlalchemy": {
"enable_commenter": True,
"commenter_options": {"db_driver": False, "db_framework": False},
},
"psycopg": {"enabled": False},
},
)
from sqlalchemy import create_engine, text # noqa: E402
engine = create_engine(os.environ["SQLALCHEMY_URL"]) # postgresql+psycopg://...
with engine.connect() as conn:
conn.execute(text("SELECT id FROM users WHERE id = :id"), {"id": 1})sqlalchemy와 psycopg 계측이 모두 로드되면 쿼리마다 클라이언트 스팬이 두 개, SQLAlchemy의 SELECT app과 psycopg의 SELECT가 생깁니다. 한 계층을 고르세요. 위처럼 psycopg를 꺼서 SQLAlchemy 스팬을 남기거나, sqlalchemy를 끄고 드라이버에서 주석을 켭니다.
Django ORM
Django의 django.db.backends.postgresql 엔진은 psycopg 3가 설치되어 있으면 이를 사용하므로, psycopg 계측이 ORM 쿼리를 트레이스하고 주석을 붙입니다.
# mysite/telemetry.py
init(
service="mysite",
instrumentations={
"psycopg": {
"enable_commenter": True,
"commenter_options": {
"db_driver": False,
"dbapi_threadsafety": False,
"dbapi_level": False,
"libpq_version": False,
"driver_paramstyle": False,
},
},
},
)# mysite/settings.py
import os
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "app",
"USER": "app",
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": "db",
"PORT": "5432",
"CONN_MAX_AGE": 60,
}
}Django 계측의 is_sql_commentor_enabled는 꺼 두세요. 드라이버가 이미 주석을 달고 있으므로 주석이 두 번 붙습니다. select_related, prefetch_related, transaction.atomic() 블록은 Django가 보내는 개별 SQL 문으로 나타납니다.
데이터베이스에서
주석은 PostgreSQL이 그것을 활용할 때만 의미가 있습니다.
pg_tracing이 있는 자체 운영 PostgreSQL은 주석이 달린 SQL 문마다 서버 측 스팬을 쿼리 클라이언트 스팬의 자식으로 기록합니다. 자체 운영 PostgreSQL을 참고하세요.- 관리형 PostgreSQL은
pg_tracing을 로드할 수 없습니다. 주석은 데이터베이스 로그에 남고, 이것이 로그를 트레이스와 연결하는 근거가 됩니다. 클라우드 관리형 PostgreSQL을 참고하세요.
pg_tracing은 샘플링된 traceparent를 가진 SQL 문만 트레이스합니다. SDK의 기본 샘플러 parentbased_always_on은 브라우저나 업스트림 서비스가 샘플링한 트레이스와 서버에서 시작한 트레이스를 모두 샘플링합니다. OTEL_TRACES_SAMPLER로 샘플링하면 데이터베이스도 같은 결정을 따릅니다.
CAUTION — 준비된 문(prepared statement)
주석의 트레이스 ID는 쿼리마다 다르므로 SQL 문 텍스트가 반복되지 않습니다. psycopg 3는 한 연결에서 같은 문이 다섯 번 실행되면 서버에서 준비하는데, 반복되지 않는 텍스트는 준비되지 않습니다. 대부분의 웹 워크로드에서는 차이가 없습니다. 서버 측 준비가 중요한 곳에서는 연결에 prepare_threshold=None을 설정해 동작을 명시하거나, 그 워크로드에서는 주석을 끄세요.
확인하기
SOPHONZ_DEBUG_PAYLOAD=true로 실행하고 요청을 보냅니다. 콘솔에 요청의 서버 스팬 아래로 쿼리마다db.statement를 가진CLIENT스팬이 출력됩니다.- 개발용 데이터베이스에서 SQL 문 로깅(
log_statement=all)을 켜고, SQL 문 끝에/*traceparent='00-...'*/가 붙는지 확인합니다. pg_tracing데이터베이스에서SELECT * FROM pg_tracing_info();를 실행하면 요청이 들어올 때마다 스팬 수가 늘어납니다.
클라이언트 스팬의 db.statement에는 주석이 포함되지 않습니다. 주석은 서버로 보낸 SQL 문에만 있습니다.
다음 단계
- 브라우저-백엔드 트레이싱 — 트레이스를 브라우저에서 시작합니다.
- PostgreSQL 계측 — 배포 형태별로 데이터베이스가 보고할 수 있는 것.
TIP — 이제 시작입니다
이제 앱을 배포하세요. Sophonz가 실사용자가 겪는 문제를 포착하고, 당신의 앱은 스스로 진화합니다.