예제
Sophonz 샘플 서버와 같은 구성으로 만든 Sophonz Python SDK의 Flask, FastAPI, Django 서버 전체 예제 — import 전 SDK 시작, 요청 헬퍼, 브라우저 트레이스를 위한 CORS, SQLCommenter를 쓰는 PostgreSQL, 로깅, 에러 처리.
API와 텔레메트리가 같은 작은 서버 세 개입니다: 브라우저에서 시작한 트레이스가 프레임워크를 거쳐 PostgreSQL까지 이어지고, 모든 스팬이 브라우저의 session.id를 가지며, 로그가 트레이스와 연결되고, 실패한 라우트는 한 번씩 기록됩니다. 모두 Sophonz 샘플 서버의 구성을 따릅니다: telemetry.py가 SDK를 시작하고, 진입점이 이를 가장 먼저 import하며, 애플리케이션은 그 뒤에 만듭니다.
데이터베이스
세 서버는 같은 테이블 두 개를 사용합니다. 로컬 PostgreSQL을 띄우고 테이블을 만드세요.
docker run -d --name example-pg -p 5432:5432 \
-e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -e POSTGRES_DB=app \
postgres:16-alpine -c log_statement=allCREATE TABLE users (
id serial PRIMARY KEY,
email text UNIQUE NOT NULL,
name text NOT NULL
);
CREATE TABLE posts (
id serial PRIMARY KEY,
author_id integer NOT NULL REFERENCES users (id),
title text NOT NULL,
body text NOT NULL
);log_statement=all은 SQL 문을 컨테이너 로그에 출력하므로 traceparent 주석이 도착하는 것을 볼 수 있습니다.
서버
myservice/
__init__.py # 빈 파일
telemetry.py
app.py
wsgi.py
gunicorn.conf.pypip install "sophonz-opentelemetry[flask,psycopg]" flask flask-cors gunicorn "psycopg[binary,pool]"# myservice/telemetry.py
import os
from sophonz.opentelemetry import init
SERVICE_NAME = os.getenv("OTEL_SERVICE_NAME", "example-flask")
# SQL 주석에 traceparent만 남깁니다. pg_tracing은 다른 값을 읽지 않습니다.
PSYCOPG = {
"enable_commenter": True,
"commenter_options": {
"db_driver": False,
"dbapi_threadsafety": False,
"dbapi_level": False,
"libpq_version": False,
"driver_paramstyle": False,
},
}
def setup_telemetry() -> bool:
return init(
service=SERVICE_NAME,
api_key=os.getenv("SOPHONZ_API_KEY"),
advanced_network_capture=True,
instrumentations={
"psycopg": PSYCOPG,
# Flask가 주석에 framework/controller/route 태그를 더하지 않게 합니다.
"flask": {"enable_commenter": False},
},
)# myservice/app.py
import logging
import os
import psycopg
from flask import Flask, jsonify, request
from flask_cors import CORS
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
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
logger = logging.getLogger("myservice")
def create_app() -> Flask:
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
CORS(
app,
origins=os.getenv("CORS_ORIGINS", "http://localhost:3000").split(","),
supports_credentials=True,
allow_headers=["content-type", "traceparent", "tracestate", "baggage"],
)
# 요청의 모든 스팬에 baggage.*, session.id, 클라이언트 IP, user agent를 붙이고,
# 라우트에서 빠져나간 예외를 한 번 기록합니다.
sophonz_flask_middleware(app)
setup_flask_error_handler(app)
# gunicorn 워커마다 풀 하나. SDK가 시작된 뒤에 만듭니다.
pool = ConnectionPool(
os.environ["DATABASE_URL"],
min_size=1,
max_size=int(os.getenv("DB_POOL_SIZE", "5")),
kwargs={"autocommit": True, "row_factory": dict_row},
open=False,
)
def connection():
if pool.closed:
pool.open(wait=False)
return pool.connection()
@app.get("/health")
def health():
return jsonify(status="ok")
@app.get("/api/users")
def list_users():
with connection() as conn:
users = conn.execute("SELECT id, email, name FROM users ORDER BY id").fetchall()
logger.info("listed users", extra={"count": len(users)})
return jsonify(users)
@app.get("/api/users/<int:user_id>")
def get_user(user_id):
with connection() as conn:
user = conn.execute(
"SELECT id, email, name FROM users WHERE id = %s", (user_id,)
).fetchone()
if user is None:
return jsonify(error="User not found"), 404
posts = conn.execute(
"SELECT id, title, body FROM posts WHERE author_id = %s ORDER BY id", (user_id,)
).fetchall()
return jsonify({**user, "posts": posts})
@app.post("/api/users")
def create_user():
payload = request.get_json(silent=True) or {}
if not payload.get("email") or not payload.get("name"):
return jsonify(error="email and name are required"), 400
try:
with connection() as conn:
user = conn.execute(
"INSERT INTO users (email, name) VALUES (%s, %s) RETURNING id, email, name",
(payload["email"], payload["name"]),
).fetchone()
except psycopg.errors.UniqueViolation:
return jsonify(error="email already exists"), 409
logger.info("created user", extra={"user_id": user["id"]})
return jsonify(user), 201
@app.get("/api/error")
def error():
raise RuntimeError("Intentional error from /api/error")
# got_request_exception 이후에 실행되므로 스팬에는 이미 예외가 있습니다.
@app.errorhandler(InternalServerError)
def internal_error(exc):
return jsonify(error=str(exc.original_exception or exc)), 500
@app.errorhandler(HTTPException)
def http_error(exc):
return jsonify(error=exc.description), exc.code
return app# myservice/wsgi.py
import logging
import os
from myservice.telemetry import setup_telemetry
# 각 gunicorn 워커에서, Flask와 psycopg를 import하기 전에.
setup_telemetry()
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
from myservice.app import create_app # noqa: E402
app = create_app()# 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
accesslog = "-"
def worker_exit(server, worker):
from sophonz.opentelemetry import shutdown
shutdown()export SOPHONZ_API_KEY=sk_...
export DATABASE_URL=postgresql://app:app@localhost:5432/app
export OTEL_METRICS_EXPORTER=none
gunicorn -c gunicorn.conf.py myservice.wsgi:appmacOS에서는 gunicorn을 위해 OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES도 설정하세요.
실행해 보기
어느 서버든 8000번 포트에서 실행한 상태로 다음을 보냅니다.
curl -s -X POST localhost:8000/api/users \
-H "content-type: application/json" \
-d '{"email": "ada@example.com", "name": "Ada"}'
curl -s localhost:8000/api/users/1 \
-H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
-H "baggage: session.id=test-session-1"
curl -s localhost:8000/api/error결과
두 번째 요청은 Sophonz Browser SDK로 계측한 브라우저가 보내는 헤더를 담고 있으며, 다음을 만듭니다.
- 트레이스
4bf92f3577b34da6a3ce929d0e0e4736에 속하고 부모가 스팬00f067aa0ba902b7인 서버 스팬 - 서버 스팬과 모든 자식 스팬의
session.id,baggage.session.id,http.client.ip,http.user_agent - 쿼리마다
psycopg클라이언트 스팬. PostgreSQL 로그에서는 각 SQL 문이/*traceparent='00-4bf92f3577b34da6a3ce929d0e0e4736-...'*/로 끝나며,pg_tracing이 이를 같은 트레이스의 데이터베이스 스팬으로 만듭니다 - 서버 스팬의 요청·응답 헤더.
authorization과cookie값은 가려집니다
나머지 요청은 다음을 만듭니다.
- 요청의 트레이스 ID와 스팬 ID를 가지고
count또는user_id를 속성으로 담아 전송되는logging레코드 /api/error는exception이벤트 하나를 가진 ERROR 서버 스팬과 에러 로그 레코드 하나- 같은 이메일로 보낸 두 번째
POST는 409 응답과, 데이터베이스가 에러를 냈으므로 자체 에러 로그 레코드를 가진 ERRORINSERT스팬
로컬에서 확인하기
SOPHONZ_DEBUG_PAYLOAD=true를 설정하면 전송하는 모든 스팬과 로그 레코드를 콘솔에도 출력합니다.
SOPHONZ_DEBUG_PAYLOAD=true gunicorn -c gunicorn.conf.py myservice.wsgi:app아무것도 출력되지 않으면 먼저 SOPHONZ_API_KEY를 확인하세요. 키가 없으면 SDK는 시작하지 않고 OpenTelemetry SDK initialization skipped를 남깁니다. 스팬은 출력되는데 Sophonz에 나타나지 않으면, 키가 등록된 앱 키인지와 OTEL_EXPORTER_OTLP_ENDPOINT가 컬렉터를 가리키는지 확인하세요.
코드 변경 없는 대안
Flask와 FastAPI 서버는 opentelemetry-instrument와 환경 변수로도 실행할 수 있습니다. SDK가 두 번 시작되지 않도록 진입점에서 setup_telemetry() 호출을 지우고, create_app()의 헬퍼 호출은 그대로 두세요. 헬퍼는 SDK를 어떻게 시작했든 동작합니다.
export SOPHONZ_API_KEY=sk_...
export OTEL_SERVICE_NAME=example-flask
export OTEL_METRICS_EXPORTER=none
export SOPHONZ_PYTHON_EXPERIMENTAL_EXCEPTION_CAPTURE=true
export SOPHONZ_PYTHON_ADVANCED_NETWORK_CAPTURE=true
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}}, "flask": {"enable_commenter": false}}'
opentelemetry-instrument gunicorn -c gunicorn.conf.py myservice.wsgi:app두 방식의 차이는 배포를, 각 파일을 이렇게 배치한 이유는 프레임워크 가이드를 참고하세요.
TIP — 이제 시작입니다
이제 앱을 배포하세요. Sophonz가 실사용자가 겪는 문제를 포착하고, 당신의 앱은 스스로 진화합니다.