Skip to content

Commit 440e40c

Browse files
committed
feat: OpenTelemetry 추가
1 parent a0db2aa commit 440e40c

8 files changed

Lines changed: 389 additions & 11 deletions

File tree

app/core/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
from os import environ
22

33
from celery import Celery
4+
from celery.signals import worker_process_init
5+
from core.observability import configure_opentelemetry
46

57
environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
68

79
celery_app = Celery("pyconkr")
810
celery_app.config_from_object("django.conf:settings", namespace="CELERY")
911
celery_app.autodiscover_tasks()
12+
13+
14+
@worker_process_init.connect
15+
def _init_opentelemetry(**_kwargs):
16+
configure_opentelemetry(role="worker")

app/core/gunicorn_conf.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import os
2+
3+
from core.observability import configure_opentelemetry
4+
5+
bind = os.environ.get("GUNICORN_BIND", "0.0.0.0:8000")
6+
workers = int(os.environ.get("GUNICORN_WORKERS", "4"))
7+
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))
8+
9+
10+
def post_fork(server, worker):
11+
configure_opentelemetry(role="api")

app/core/logger/filter/otel.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from logging import Filter, LogRecord
2+
3+
from opentelemetry import trace
4+
5+
6+
class OtelTraceContextFilter(Filter):
7+
def filter(self, record: LogRecord) -> bool:
8+
ctx = trace.get_current_span().get_span_context()
9+
if ctx.is_valid:
10+
record.otelTraceID = format(ctx.trace_id, "032x")
11+
record.otelSpanID = format(ctx.span_id, "016x")
12+
else:
13+
record.otelTraceID = "0"
14+
record.otelSpanID = "0"
15+
return True

app/core/observability.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import logging
2+
import os
3+
from typing import Literal
4+
5+
from opentelemetry import metrics, trace
6+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
7+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
8+
from opentelemetry.instrumentation.celery import CeleryInstrumentor
9+
from opentelemetry.instrumentation.django import DjangoInstrumentor
10+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
11+
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
12+
from opentelemetry.instrumentation.redis import RedisInstrumentor
13+
from opentelemetry.instrumentation.requests import RequestsInstrumentor
14+
from opentelemetry.sdk.metrics import MeterProvider
15+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
16+
from opentelemetry.sdk.resources import Resource
17+
from opentelemetry.sdk.trace import TracerProvider
18+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
19+
20+
RoleType = Literal["api", "worker"]
21+
logger = logging.getLogger(__name__)
22+
23+
_configured = False
24+
25+
26+
def _flag(name: str, default: bool) -> bool:
27+
return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "on")
28+
29+
30+
def _get_otlp_endpoint() -> str | None:
31+
return os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
32+
33+
34+
def configure_opentelemetry(role: RoleType) -> None:
35+
global _configured
36+
if _configured or _flag("OTEL_SDK_DISABLED", False) or not _get_otlp_endpoint():
37+
return
38+
_configured = True
39+
40+
try:
41+
resource = Resource.create(
42+
{
43+
"service.name": os.environ.get("OTEL_SERVICE_NAME", f"pyconkr-{role}"),
44+
"service.namespace": "pyconkr",
45+
"pyconkr.process_role": role,
46+
"service.version": os.environ.get("DEPLOYMENT_RELEASE_VERSION", "unknown"),
47+
"deployment.environment": os.environ.get("API_STAGE", "unknown"),
48+
}
49+
)
50+
51+
if _flag("OTEL_TRACES_ENABLED", True):
52+
provider = TracerProvider(resource=resource)
53+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
54+
trace.set_tracer_provider(provider)
55+
if _flag("OTEL_METRICS_ENABLED", True):
56+
metrics.set_meter_provider(
57+
MeterProvider(
58+
resource=resource,
59+
metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())],
60+
)
61+
)
62+
DjangoInstrumentor().instrument()
63+
PsycopgInstrumentor().instrument()
64+
CeleryInstrumentor().instrument()
65+
RedisInstrumentor().instrument()
66+
HTTPXClientInstrumentor().instrument()
67+
RequestsInstrumentor().instrument()
68+
69+
logger.info("OpenTelemetry configured (role=%s)", role)
70+
except Exception:
71+
logger.exception("OpenTelemetry 초기화 실패 — 계측 없이 계속 진행")

app/core/settings.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,19 @@
4141
"version": 1,
4242
"disable_existing_loggers": False,
4343
"formatters": {
44-
"basic": {"format": "%(asctime)s:%(module)s:%(levelname)s:%(message)s", "datefmt": "%Y-%m-%d %H:%M:%S"},
44+
"basic": {
45+
"format": "%(asctime)s:%(module)s:%(levelname)s:%(message)s trace_id=%(otelTraceID)s",
46+
"datefmt": "%Y-%m-%d %H:%M:%S",
47+
},
4548
"slack": {"()": "core.logger.formatter.slack.SlackJsonFormatter"},
4649
},
50+
"filters": {"otel_trace": {"()": "core.logger.filter.otel.OtelTraceContextFilter"}},
4751
"handlers": {
4852
"console": {
4953
"level": LOG_LEVEL,
5054
"class": "logging.StreamHandler",
5155
"formatter": "basic",
56+
"filters": ["otel_trace"],
5257
},
5358
"slack": {
5459
"level": LOG_LEVEL,

infra/server.Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ EXPOSE 8000
4949

5050
# The reason for using nobody user is to avoid running the app as root, which can be a security risk.
5151
USER nobody
52-
CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4", "--timeout", "30"]
52+
CMD ["gunicorn", "core.wsgi:application", "-c", "core/gunicorn_conf.py"]

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ dependencies = [
4040
"shortuuid>=1.0.13",
4141
"qrcode[pil]>=8.0",
4242
"weasyprint>=63.0",
43+
"opentelemetry-sdk>=1.42.1",
44+
"opentelemetry-exporter-otlp-proto-http>=1.42.1",
45+
"opentelemetry-instrumentation-django>=0.63b1",
46+
"opentelemetry-instrumentation-psycopg>=0.63b1",
47+
"opentelemetry-instrumentation-celery>=0.63b1",
48+
"opentelemetry-instrumentation-redis>=0.63b1",
49+
"opentelemetry-instrumentation-httpx>=0.63b1",
50+
"opentelemetry-instrumentation-requests>=0.63b1",
4351
]
4452

4553
[dependency-groups]

0 commit comments

Comments
 (0)