예제

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=all
CREATE 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.py
pip 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:app

macOS에서는 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 응답과, 데이터베이스가 에러를 냈으므로 자체 에러 로그 레코드를 가진 ERROR INSERT 스팬

로컬에서 확인하기

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가 실사용자가 겪는 문제를 포착하고, 당신의 앱은 스스로 진화합니다.