From c1ca2f2af14d6b188172c91fc1874069d1eae55b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 10:37:01 +0100 Subject: [PATCH 1/3] feat: Django-free structlog to OTel integration via otel extra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an `otel` extra carrying the dependencies of common.core.logging and common.core.otel, and import the Django, psycopg2 and Redis instrumentors lazily in setup_tracing — the only place they are used — so both modules import without Django installed. Enables non-Django services (first up: the MCP server) to reuse the structlog -> OTel processor chain. beep boop --- pyproject.toml | 8 ++++++++ src/common/core/otel.py | 7 ++++--- tests/unit/common/core/test_otel.py | 27 +++++++++++++++++++++------ uv.lock | 18 +++++++++++++++++- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec95f822..976cec43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,14 @@ optional-dependencies = { test-tools = [ "simplejson (>=3,<4)", "structlog (>=24.4,<26)", "typing_extensions", +], otel = [ + "inflection", + "opentelemetry-api (>=1.25,<2)", + "opentelemetry-sdk (>=1.25,<2)", + "opentelemetry-exporter-otlp-proto-http (>=1.25,<2)", + "sentry-sdk (>=2.0.0,<3.0.0)", + "structlog (>=24.4,<26)", + "typing_extensions", ], task-processor = [ "backoff (>=2.2.1,<3.0.0)", "django (>4,<6)", diff --git a/src/common/core/otel.py b/src/common/core/otel.py index 50daba0b..414182eb 100644 --- a/src/common/core/otel.py +++ b/src/common/core/otel.py @@ -17,9 +17,6 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( OTLPSpanExporter, ) -from opentelemetry.instrumentation.django import DjangoInstrumentor -from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor -from opentelemetry.instrumentation.redis import RedisInstrumentor from opentelemetry.propagate import set_global_textmap from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.propagators.textmap import TextMapPropagator @@ -195,6 +192,10 @@ def setup_tracing( (e.g. ``"health/liveness,health/readiness"``). If not provided, falls back to the ``OTEL_PYTHON_DJANGO_EXCLUDED_URLS`` env var. """ + from opentelemetry.instrumentation.django import DjangoInstrumentor + from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor + from opentelemetry.instrumentation.redis import RedisInstrumentor + trace.set_tracer_provider(tracer_provider) propagator: TextMapPropagator = CompositePropagator( diff --git a/tests/unit/common/core/test_otel.py b/tests/unit/common/core/test_otel.py index 69152370..6bc7a31a 100644 --- a/tests/unit/common/core/test_otel.py +++ b/tests/unit/common/core/test_otel.py @@ -210,16 +210,20 @@ def test_setup_tracing__called__instruments_and_uninstruments_all_libraries( mocker: pytest_mock.MockerFixture, ) -> None: # Given - mocker.patch("common.core.otel.DjangoInstrumentor.instrument") - mocker.patch("common.core.otel.DjangoInstrumentor.uninstrument") + mocker.patch("opentelemetry.instrumentation.django.DjangoInstrumentor.instrument") + mocker.patch("opentelemetry.instrumentation.django.DjangoInstrumentor.uninstrument") psycopg2_instrument = mocker.patch( - "common.core.otel.Psycopg2Instrumentor.instrument" + "opentelemetry.instrumentation.psycopg2.Psycopg2Instrumentor.instrument" ) psycopg2_uninstrument = mocker.patch( - "common.core.otel.Psycopg2Instrumentor.uninstrument" + "opentelemetry.instrumentation.psycopg2.Psycopg2Instrumentor.uninstrument" + ) + redis_instrument = mocker.patch( + "opentelemetry.instrumentation.redis.RedisInstrumentor.instrument" + ) + redis_uninstrument = mocker.patch( + "opentelemetry.instrumentation.redis.RedisInstrumentor.uninstrument" ) - redis_instrument = mocker.patch("common.core.otel.RedisInstrumentor.instrument") - redis_uninstrument = mocker.patch("common.core.otel.RedisInstrumentor.uninstrument") provider = TracerProvider() # When @@ -232,3 +236,14 @@ def test_setup_tracing__called__instruments_and_uninstruments_all_libraries( # Then — uninstrumented on exit psycopg2_uninstrument.assert_called_once() redis_uninstrument.assert_called_once() + + +def test_otel_module__imported__no_module_level_instrumentor_references() -> None: + """Lazy instrumentor imports keep the module importable without Django.""" + # Given / When + import common.core.otel as otel_module + + # Then + assert not hasattr(otel_module, "DjangoInstrumentor") + assert not hasattr(otel_module, "Psycopg2Instrumentor") + assert not hasattr(otel_module, "RedisInstrumentor") diff --git a/uv.lock b/uv.lock index 371e1289..3032a9cf 100644 --- a/uv.lock +++ b/uv.lock @@ -483,6 +483,15 @@ flagsmith-schemas = [ { name = "simplejson" }, { name = "typing-extensions" }, ] +otel = [ + { name = "inflection" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "sentry-sdk" }, + { name = "structlog" }, + { name = "typing-extensions" }, +] task-processor = [ { name = "backoff" }, { name = "django" }, @@ -532,13 +541,17 @@ requires-dist = [ { name = "flagsmith-flag-engine", marker = "extra == 'flagsmith-schemas'", specifier = ">6" }, { name = "gunicorn", marker = "extra == 'common-core'", specifier = ">=19.1" }, { name = "inflection", marker = "extra == 'common-core'" }, + { name = "inflection", marker = "extra == 'otel'" }, { name = "opentelemetry-api", marker = "extra == 'common-core'", specifier = ">=1.25,<2" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.25,<2" }, { name = "opentelemetry-api", marker = "extra == 'task-processor'", specifier = ">=1.25,<2" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'common-core'", specifier = ">=1.25,<2" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.25,<2" }, { name = "opentelemetry-instrumentation-django", marker = "extra == 'common-core'", specifier = ">=0.46b0,<1" }, { name = "opentelemetry-instrumentation-psycopg2", marker = "extra == 'common-core'", specifier = ">=0.46b0,<1" }, { name = "opentelemetry-instrumentation-redis", marker = "extra == 'common-core'", specifier = ">=0.46b0,<1" }, { name = "opentelemetry-sdk", marker = "extra == 'common-core'", specifier = ">=1.25,<2" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.25,<2" }, { name = "prometheus-client", marker = "extra == 'common-core'", specifier = ">=0.0.16" }, { name = "prometheus-client", marker = "extra == 'task-processor'", specifier = ">=0.0.16" }, { name = "psycopg2-binary", marker = "extra == 'common-core'", specifier = ">=2.9,<3" }, @@ -547,13 +560,16 @@ requires-dist = [ { name = "redis", marker = "extra == 'common-core'", specifier = ">=5,<6" }, { name = "requests", marker = "extra == 'common-core'" }, { name = "sentry-sdk", marker = "extra == 'common-core'", specifier = ">=2.0.0,<3.0.0" }, + { name = "sentry-sdk", marker = "extra == 'otel'", specifier = ">=2.0.0,<3.0.0" }, { name = "simplejson", marker = "extra == 'common-core'", specifier = ">=3,<4" }, { name = "simplejson", marker = "extra == 'flagsmith-schemas'" }, { name = "structlog", marker = "extra == 'common-core'", specifier = ">=24.4,<26" }, + { name = "structlog", marker = "extra == 'otel'", specifier = ">=24.4,<26" }, { name = "typing-extensions", marker = "extra == 'common-core'" }, { name = "typing-extensions", marker = "extra == 'flagsmith-schemas'" }, + { name = "typing-extensions", marker = "extra == 'otel'" }, ] -provides-extras = ["test-tools", "common-core", "task-processor", "flagsmith-schemas"] +provides-extras = ["test-tools", "common-core", "otel", "task-processor", "flagsmith-schemas"] [package.metadata.requires-dev] dev = [ From f9caea54d27ffe6e2486586736acf6f9354e95ef Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 10:42:53 +0100 Subject: [PATCH 2/3] refactor: DRY common-core dependencies via the otel extra beep boop --- pyproject.toml | 8 +------- uv.lock | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 976cec43..3d489422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ optional-dependencies = { test-tools = [ "pyfakefs (>=5,<6)", "pytest-django (>=4,<5)", ], common-core = [ + "flagsmith-common[otel]", "django (>4,<6)", "django-health-check", "djangorestframework-recursive", @@ -16,10 +17,6 @@ optional-dependencies = { test-tools = [ "drf-writable-nested", "environs (<16)", "gunicorn (>=19.1)", - "inflection", - "opentelemetry-api (>=1.25,<2)", - "opentelemetry-sdk (>=1.25,<2)", - "opentelemetry-exporter-otlp-proto-http (>=1.25,<2)", "opentelemetry-instrumentation-django (>=0.46b0,<1)", "opentelemetry-instrumentation-psycopg2 (>=0.46b0,<1)", "opentelemetry-instrumentation-redis (>=0.46b0,<1)", @@ -27,10 +24,7 @@ optional-dependencies = { test-tools = [ "prometheus-client (>=0.0.16)", "psycopg2-binary (>=2.9,<3)", "requests", - "sentry-sdk (>=2.0.0,<3.0.0)", "simplejson (>=3,<4)", - "structlog (>=24.4,<26)", - "typing_extensions", ], otel = [ "inflection", "opentelemetry-api (>=1.25,<2)", diff --git a/uv.lock b/uv.lock index 3032a9cf..f362922d 100644 --- a/uv.lock +++ b/uv.lock @@ -538,19 +538,16 @@ requires-dist = [ { name = "drf-spectacular", marker = "extra == 'common-core'", specifier = ">=0.28.0,<1" }, { name = "drf-writable-nested", marker = "extra == 'common-core'" }, { name = "environs", marker = "extra == 'common-core'", specifier = "<16" }, + { name = "flagsmith-common", extras = ["otel"], marker = "extra == 'common-core'" }, { name = "flagsmith-flag-engine", marker = "extra == 'flagsmith-schemas'", specifier = ">6" }, { name = "gunicorn", marker = "extra == 'common-core'", specifier = ">=19.1" }, - { name = "inflection", marker = "extra == 'common-core'" }, { name = "inflection", marker = "extra == 'otel'" }, - { name = "opentelemetry-api", marker = "extra == 'common-core'", specifier = ">=1.25,<2" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.25,<2" }, { name = "opentelemetry-api", marker = "extra == 'task-processor'", specifier = ">=1.25,<2" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'common-core'", specifier = ">=1.25,<2" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.25,<2" }, { name = "opentelemetry-instrumentation-django", marker = "extra == 'common-core'", specifier = ">=0.46b0,<1" }, { name = "opentelemetry-instrumentation-psycopg2", marker = "extra == 'common-core'", specifier = ">=0.46b0,<1" }, { name = "opentelemetry-instrumentation-redis", marker = "extra == 'common-core'", specifier = ">=0.46b0,<1" }, - { name = "opentelemetry-sdk", marker = "extra == 'common-core'", specifier = ">=1.25,<2" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.25,<2" }, { name = "prometheus-client", marker = "extra == 'common-core'", specifier = ">=0.0.16" }, { name = "prometheus-client", marker = "extra == 'task-processor'", specifier = ">=0.0.16" }, @@ -559,13 +556,10 @@ requires-dist = [ { name = "pytest-django", marker = "extra == 'test-tools'", specifier = ">=4,<5" }, { name = "redis", marker = "extra == 'common-core'", specifier = ">=5,<6" }, { name = "requests", marker = "extra == 'common-core'" }, - { name = "sentry-sdk", marker = "extra == 'common-core'", specifier = ">=2.0.0,<3.0.0" }, { name = "sentry-sdk", marker = "extra == 'otel'", specifier = ">=2.0.0,<3.0.0" }, { name = "simplejson", marker = "extra == 'common-core'", specifier = ">=3,<4" }, { name = "simplejson", marker = "extra == 'flagsmith-schemas'" }, - { name = "structlog", marker = "extra == 'common-core'", specifier = ">=24.4,<26" }, { name = "structlog", marker = "extra == 'otel'", specifier = ">=24.4,<26" }, - { name = "typing-extensions", marker = "extra == 'common-core'" }, { name = "typing-extensions", marker = "extra == 'flagsmith-schemas'" }, { name = "typing-extensions", marker = "extra == 'otel'" }, ] From 999c78524e0c52c7ecb82040c3a5e6b4a2a7c3c7 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 11:04:31 +0100 Subject: [PATCH 3/3] Remove docstring from test_otel_module function Remove unnecessary docstring from test function. --- tests/unit/common/core/test_otel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/common/core/test_otel.py b/tests/unit/common/core/test_otel.py index 6bc7a31a..afd8a0f0 100644 --- a/tests/unit/common/core/test_otel.py +++ b/tests/unit/common/core/test_otel.py @@ -239,7 +239,6 @@ def test_setup_tracing__called__instruments_and_uninstruments_all_libraries( def test_otel_module__imported__no_module_level_instrumentor_references() -> None: - """Lazy instrumentor imports keep the module importable without Django.""" # Given / When import common.core.otel as otel_module