refactor(api/observability): unify OTEL_ENABLED gate, isolate instrumentors, pass sampler vars through compose

- Extract shared is_otel_enabled() + extend_logging_config() into
  plane.observability.logging so the bootstrap, Celery, and Django LOGGING
  gates all accept the documented tokens (incl. 'on') identically.
- Isolate each instrumentor in _instrument_libraries() so one failure can't
  block startup.
- Pass OTEL_TRACES_SAMPLER / _ARG / OTEL_RESOURCE_ATTRIBUTES through the
  x-otel-env compose anchor (documented in .env.example).
- Reset _TRACER_PROVIDER/_METER_PROVIDER in the test fixture.
This commit is contained in:
sriram veeraghanta
2026-07-14 13:27:40 +05:30
parent 38306e3893
commit 9a7d840761
7 changed files with 79 additions and 50 deletions

View File

@@ -26,14 +26,14 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
# Bootstrap OpenTelemetry before Celery wires up so CeleryInstrumentor can
# patch task execution. No-op unless OTEL_ENABLED=1.
from plane.observability.setup import configure_otel, flush_otel # noqa: E402
from plane.observability.logging import TraceContextFilter # noqa: E402
from plane.observability.logging import TraceContextFilter, is_otel_enabled # noqa: E402
configure_otel()
# Whether to trace-correlate worker logs. Matches the Django LOGGING gate in
# plane/settings/{local,production}.py; the bootstrap in configure_otel() uses
# its own (superset) token check.
_OTEL_LOG_ENABLED = os.environ.get("OTEL_ENABLED", "0").strip().lower() in ("1", "true", "yes")
# Whether to trace-correlate worker logs. Uses the same shared is_otel_enabled()
# gate as the bootstrap (setup.configure_otel) and the Django LOGGING gate, so
# every documented OTEL_ENABLED token behaves identically across processes.
_OTEL_LOG_ENABLED = is_otel_enabled()
# Base JSON log fmt (unchanged off-path); the OTel variant appends the
# trace-context fields that TraceContextFilter populates.

View File

@@ -19,6 +19,21 @@ import os
from opentelemetry import trace
# Accepted OTEL_ENABLED tokens — the single source of truth shared by the
# bootstrap gate (setup.configure_otel), the Celery worker-log gate, and the
# Django LOGGING gate, so enabling via any documented token (see the README)
# behaves identically everywhere.
_TRUTHY_VALUES = ("1", "true", "yes", "on")
# Trace-context fields appended to the JSON log formatter when OTel is enabled;
# populated by TraceContextFilter.
_TRACE_LOG_FIELDS = "%(service_name)s %(trace_id)s %(span_id)s %(trace_flags)s"
def is_otel_enabled() -> bool:
"""Return True when OTEL_ENABLED is set to a recognized truthy token."""
return os.environ.get("OTEL_ENABLED", "0").strip().lower() in _TRUTHY_VALUES
class TraceContextFilter(logging.Filter):
"""Inject trace_id, span_id, trace_flags, service_name into LogRecord."""
@@ -35,3 +50,27 @@ class TraceContextFilter(logging.Filter):
record.trace_flags = 0
record.service_name = os.environ.get("OTEL_SERVICE_NAME", "plane-api")
return True
def extend_logging_config(logging_config: dict) -> None:
"""Trace-correlate a Django LOGGING dict in place. No-op unless OTel is enabled.
When enabled, extends the JSON formatter's fmt with the trace-context fields
and attaches TraceContextFilter to every handler. Attaching at the handler
level (rather than the root logger) is required because most plane.* loggers
set propagate=False; runtime mutation also wouldn't survive Django's
dictConfig. The off path leaves the log schema byte-for-byte unchanged.
Shared by settings/local.py and settings/production.py so the two stay in
lockstep.
"""
if not is_otel_enabled():
return
logging_config["formatters"]["json"]["fmt"] = (
"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s " + _TRACE_LOG_FIELDS
)
logging_config.setdefault("filters", {})["trace_context"] = {
"()": "plane.observability.logging.TraceContextFilter",
}
for handler in logging_config["handlers"].values():
handler["filters"] = ["trace_context"]

View File

@@ -24,6 +24,8 @@ from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from plane.observability.logging import is_otel_enabled
logger = logging.getLogger(__name__)
_CONFIGURED = False
@@ -33,10 +35,6 @@ _CONFIGURED = False
_TRACER_PROVIDER: "TracerProvider | None" = None
_METER_PROVIDER: "MeterProvider | None" = None
# Accepted "on" tokens — kept in sync with the pi/node services so OTEL_ENABLED
# behaves identically across every runtime.
_TRUTHY_VALUES = ("1", "true", "yes", "on")
_NOISY_OTEL_LOGGERS = (
"opentelemetry",
"opentelemetry.exporter.otlp",
@@ -49,10 +47,6 @@ _NOISY_OTEL_LOGGERS = (
_HTTP_PROTOCOLS = ("http/protobuf", "http")
def _is_enabled() -> bool:
return os.environ.get("OTEL_ENABLED", "0").strip().lower() in _TRUTHY_VALUES
def _has_endpoint() -> bool:
return bool(os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip())
@@ -140,13 +134,24 @@ def _instrument_libraries() -> None:
request that enqueues a task is linked to that task's execution span.
The others add child spans so latency can be decomposed (SQL query,
Redis op, outbound HTTP).
Each instrumentor is isolated: a single failure (version mismatch, missing
optional dependency) is logged and skipped so it neither blocks the other
instrumentors nor takes down application startup.
"""
DjangoInstrumentor().instrument()
CeleryInstrumentor().instrument()
PsycopgInstrumentor().instrument(enable_commenter=False)
RedisInstrumentor().instrument()
RequestsInstrumentor().instrument()
HTTPXClientInstrumentor().instrument()
instrumentors = (
(DjangoInstrumentor, {}),
(CeleryInstrumentor, {}),
(PsycopgInstrumentor, {"enable_commenter": False}),
(RedisInstrumentor, {}),
(RequestsInstrumentor, {}),
(HTTPXClientInstrumentor, {}),
)
for instrumentor_cls, kwargs in instrumentors:
try:
instrumentor_cls().instrument(**kwargs)
except Exception as exc:
logger.warning("Failed to instrument %s: %s", instrumentor_cls.__name__, exc)
def _quiet_otel_loggers() -> None:
@@ -172,7 +177,7 @@ def configure_otel() -> None:
global _CONFIGURED
if _CONFIGURED:
return
if not _is_enabled():
if not is_otel_enabled():
return
if not _has_endpoint():
logger.warning("OTEL_ENABLED=1 but OTEL_EXPORTER_OTLP_ENDPOINT is not set; OpenTelemetry bootstrap skipped")

View File

@@ -88,18 +88,8 @@ LOGGING = {
},
}
# OpenTelemetry APM: only when OTEL_ENABLED=1 do we extend the JSON
# formatter and attach TraceContextFilter to every handler. Attaching at
# the handler level (rather than the root logger) is required because
# most plane.* loggers have propagate=False; runtime mutation also wouldn't
# survive Django's dictConfig. Off path leaves the log schema unchanged.
if os.environ.get("OTEL_ENABLED", "0").lower() in ("1", "true", "yes"):
LOGGING["formatters"]["json"]["fmt"] = (
"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s "
"%(service_name)s %(trace_id)s %(span_id)s %(trace_flags)s"
)
LOGGING["filters"] = {
"trace_context": {"()": "plane.observability.logging.TraceContextFilter"},
}
for _handler in LOGGING["handlers"].values():
_handler["filters"] = ["trace_context"]
# OpenTelemetry APM: trace-correlate the JSON logs when OTEL is enabled.
# No-op (log schema unchanged) otherwise. Shared with settings/production.py.
from plane.observability.logging import extend_logging_config # noqa: E402
extend_logging_config(LOGGING)

View File

@@ -98,18 +98,8 @@ LOGGING = {
},
}
# OpenTelemetry APM: only when OTEL_ENABLED=1 do we extend the JSON
# formatter and attach TraceContextFilter to every handler. Attaching at
# the handler level (rather than the root logger) is required because
# most plane.* loggers have propagate=False; runtime mutation also wouldn't
# survive Django's dictConfig. Off path leaves the log schema unchanged.
if os.environ.get("OTEL_ENABLED", "0").lower() in ("1", "true", "yes"):
LOGGING["formatters"]["json"]["fmt"] = (
"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s "
"%(service_name)s %(trace_id)s %(span_id)s %(trace_flags)s"
)
LOGGING["filters"] = {
"trace_context": {"()": "plane.observability.logging.TraceContextFilter"},
}
for _handler in LOGGING["handlers"].values():
_handler["filters"] = ["trace_context"]
# OpenTelemetry APM: trace-correlate the JSON logs when OTEL is enabled.
# No-op (log schema unchanged) otherwise. Shared with settings/local.py.
from plane.observability.logging import extend_logging_config # noqa: E402
extend_logging_config(LOGGING)

View File

@@ -25,6 +25,8 @@ def isolate_otel_state(monkeypatch):
from plane.observability import setup as otel_setup
monkeypatch.setattr(otel_setup, "_CONFIGURED", False, raising=False)
monkeypatch.setattr(otel_setup, "_TRACER_PROVIDER", None, raising=False)
monkeypatch.setattr(otel_setup, "_METER_PROVIDER", None, raising=False)
saved = {k: v for k, v in os.environ.items() if k.startswith("OTEL_")}
for key in list(saved.keys()):

View File

@@ -70,6 +70,9 @@ x-otel-env: &otel-env
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-plane-api}
OTEL_ENVIRONMENT: ${OTEL_ENVIRONMENT:-}
OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-}
OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-}
OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-}
services:
web: