flagsmith-common 3.5.0__tar.gz → 3.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/PKG-INFO +57 -1
  2. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/README.md +48 -0
  3. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/pyproject.toml +9 -1
  4. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/logging.py +6 -1
  5. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/main.py +30 -0
  6. flagsmith_common-3.6.0/src/common/core/otel.py +204 -0
  7. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/middleware.py +8 -2
  8. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/LICENSE +0 -0
  9. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/__init__.py +0 -0
  10. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/__init__.py +0 -0
  11. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/app.py +0 -0
  12. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/cli/__init__.py +0 -0
  13. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/cli/healthcheck.py +0 -0
  14. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/constants.py +0 -0
  15. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/management/__init__.py +0 -0
  16. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/__init__.py +0 -0
  17. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/docgen.py +0 -0
  18. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/start.py +0 -0
  19. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/waitfordb.py +0 -0
  20. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/metrics.py +0 -0
  21. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/middleware.py +0 -0
  22. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/sentry.py +0 -0
  23. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/templates/docgen-metrics.md +0 -0
  24. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/urls.py +0 -0
  25. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/utils.py +0 -0
  26. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/core/views.py +0 -0
  27. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/environments/permissions.py +0 -0
  28. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/features/__init__.py +0 -0
  29. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/features/multivariate/__init__.py +0 -0
  30. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/features/multivariate/serializers.py +0 -0
  31. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/features/serializers.py +0 -0
  32. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/features/versioning/__init__.py +0 -0
  33. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/features/versioning/serializers.py +0 -0
  34. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/__init__.py +0 -0
  35. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/conf.py +0 -0
  36. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/constants.py +0 -0
  37. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/logging.py +0 -0
  38. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/metrics.py +0 -0
  39. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/metrics_server.py +0 -0
  40. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/processors.py +0 -0
  41. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/gunicorn/utils.py +0 -0
  42. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/lint_tests.py +0 -0
  43. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/migrations/__init__.py +0 -0
  44. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/migrations/helpers/__init__.py +0 -0
  45. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  46. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/organisations/permissions.py +0 -0
  47. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/projects/permissions.py +0 -0
  48. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/prometheus/__init__.py +0 -0
  49. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/prometheus/utils.py +0 -0
  50. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/py.typed +0 -0
  51. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/test_tools/__init__.py +0 -0
  52. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/test_tools/plugin.py +0 -0
  53. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/test_tools/types.py +0 -0
  54. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/test_tools/utils.py +0 -0
  55. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/common/types.py +0 -0
  56. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/__init__.py +0 -0
  57. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/api.py +0 -0
  58. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/constants.py +0 -0
  59. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/dynamodb.py +0 -0
  60. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/py.typed +0 -0
  61. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
  62. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/types.py +0 -0
  63. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/utils.py +0 -0
  64. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/validators.py +0 -0
  65. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/__init__.py +0 -0
  66. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/admin.py +0 -0
  67. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/apps.py +0 -0
  68. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/decorators.py +0 -0
  69. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/exceptions.py +0 -0
  70. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/health.py +0 -0
  71. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/managers.py +0 -0
  72. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/metrics.py +0 -0
  73. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0001_initial.py +0 -0
  74. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  75. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  76. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  77. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  78. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  79. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  80. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  81. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
  82. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
  83. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
  84. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  85. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  86. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/__init__.py +0 -0
  87. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  88. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  89. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  90. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  91. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  92. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/__init__.py +0 -0
  93. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/models.py +0 -0
  94. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/monitoring.py +0 -0
  95. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/processor.py +0 -0
  96. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/py.typed +0 -0
  97. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/routers.py +0 -0
  98. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/serializers.py +0 -0
  99. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/task_registry.py +0 -0
  100. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/task_run_method.py +0 -0
  101. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/tasks.py +0 -0
  102. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/threads.py +0 -0
  103. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/types.py +0 -0
  104. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/urls.py +0 -0
  105. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/utils.py +0 -0
  106. {flagsmith_common-3.5.0 → flagsmith_common-3.6.0}/src/task_processor/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-common
3
- Version: 3.5.0
3
+ Version: 3.6.0
4
4
  Summary: Flagsmith's common library
5
5
  Author: Matthew Elwell, Gagan Trivedi, Kim Gustyr, Zach Aysan, Francesco Lo Franco, Rodrigo López Dato, Evandro Myller, Wadii Zaim
6
6
  License-Expression: BSD-3-Clause
@@ -17,6 +17,14 @@ Requires-Dist: drf-spectacular>=0.28.0,<1 ; extra == 'common-core'
17
17
  Requires-Dist: drf-writable-nested ; extra == 'common-core'
18
18
  Requires-Dist: environs<15 ; extra == 'common-core'
19
19
  Requires-Dist: gunicorn>=19.1 ; extra == 'common-core'
20
+ Requires-Dist: inflection ; extra == 'common-core'
21
+ Requires-Dist: opentelemetry-api>=1.25,<2 ; extra == 'common-core'
22
+ Requires-Dist: opentelemetry-sdk>=1.25,<2 ; extra == 'common-core'
23
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.25,<2 ; extra == 'common-core'
24
+ Requires-Dist: opentelemetry-instrumentation-django>=0.46b0,<1 ; extra == 'common-core'
25
+ Requires-Dist: opentelemetry-instrumentation-psycopg2>=0.46b0,<1 ; extra == 'common-core'
26
+ Requires-Dist: opentelemetry-instrumentation-redis>=0.46b0,<1 ; extra == 'common-core'
27
+ Requires-Dist: redis>=5,<6 ; extra == 'common-core'
20
28
  Requires-Dist: prometheus-client>=0.0.16 ; extra == 'common-core'
21
29
  Requires-Dist: psycopg2-binary>=2.9,<3 ; extra == 'common-core'
22
30
  Requires-Dist: requests ; extra == 'common-core'
@@ -145,6 +153,54 @@ Use this mark to auto-use the `saas_mode` fixture.
145
153
 
146
154
  Use this mark to auto-use the `enterprise_mode` fixture.
147
155
 
156
+ ### OpenTelemetry
157
+
158
+ Flagsmith supports exporting traces and structured logs over OTLP.
159
+
160
+ #### Configuration
161
+
162
+ OTel instrumentation is opt-in, controlled by environment variables:
163
+
164
+ | Variable | Description | Default |
165
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------- |
166
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint (e.g. `http://collector:4318`). If unset, no OTel setup occurs. | _(disabled)_ |
167
+ | `OTEL_SERVICE_NAME` | The `service.name` resource attribute. | `flagsmith-api` |
168
+ | `OTEL_TRACING_EXCLUDED_URL_PATHS` | Comma-separated URL paths to exclude from tracing (e.g. `health/liveness,health/readiness`). | _(none)_ |
169
+
170
+ Standard `OTEL_*` env vars (e.g. `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_EXPORTER_OTLP_HEADERS`) are also respected by the OTel SDK.
171
+
172
+ #### What gets configured
173
+
174
+ When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, `ensure_cli_env()` sets up:
175
+
176
+ - **Tracing**: `TracerProvider` with OTLP/HTTP span export, W3C `TraceContext` + `Baggage` propagation, and auto-instrumentation for:
177
+ - **Django** (`DjangoInstrumentor`): creates a root span per HTTP request with span names formatted as `{METHOD} {route_template}` (e.g. `GET /api/v1/projects/{pk}/`).
178
+ - **psycopg2** (`Psycopg2Instrumentor`): creates child spans for each SQL query with `db.system`, `db.statement`, and `db.name` attributes. SQL commenter is enabled, adding trace context as SQL comments for database-side correlation.
179
+ - **Redis** (`RedisInstrumentor`): creates child spans for each Redis command with `db.system` and `db.statement` attributes.
180
+ - **Structured log export**: A structlog processor that emits each log event as both an OTLP log record and a span event (when an active span exists).
181
+
182
+ #### Emitting OTel log events via structlog
183
+
184
+ Use structlog as usual. The OTel processor captures events and maps them to OTLP log records:
185
+
186
+ ```python
187
+ import structlog
188
+
189
+ log = structlog.get_logger("code_references")
190
+ log.info("scan-created", code_references__count=3, feature__count=2)
191
+ ```
192
+
193
+ This produces:
194
+
195
+ 1. An **OTLP log record** with:
196
+ - `Body: scan-created`
197
+ - `EventName: code_references.scan_created` (logger name + `inflection.underscore` of the event)
198
+ - `Severity: INFO`
199
+ - `Attributes: code_references.count=3, feature.count=2` (double underscores are converted to dots)
200
+ - W3C Baggage entries from the current OTel context are copied into log attributes (e.g. `amplitude.device_id`, `amplitude.session_id`).
201
+
202
+ 2. A **span event** on the active span (if one exists) with the same name and attributes. This makes structlog events visible in trace backends (e.g. SigNoz's "Events" tab) without requiring separate log correlation. When no span is active (e.g. during startup or management commands), only the OTLP log record is emitted.
203
+
148
204
  ### Metrics
149
205
 
150
206
  Flagsmith uses Prometheus to track performance metrics.
@@ -96,6 +96,54 @@ Use this mark to auto-use the `saas_mode` fixture.
96
96
 
97
97
  Use this mark to auto-use the `enterprise_mode` fixture.
98
98
 
99
+ ### OpenTelemetry
100
+
101
+ Flagsmith supports exporting traces and structured logs over OTLP.
102
+
103
+ #### Configuration
104
+
105
+ OTel instrumentation is opt-in, controlled by environment variables:
106
+
107
+ | Variable | Description | Default |
108
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------- |
109
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint (e.g. `http://collector:4318`). If unset, no OTel setup occurs. | _(disabled)_ |
110
+ | `OTEL_SERVICE_NAME` | The `service.name` resource attribute. | `flagsmith-api` |
111
+ | `OTEL_TRACING_EXCLUDED_URL_PATHS` | Comma-separated URL paths to exclude from tracing (e.g. `health/liveness,health/readiness`). | _(none)_ |
112
+
113
+ Standard `OTEL_*` env vars (e.g. `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_EXPORTER_OTLP_HEADERS`) are also respected by the OTel SDK.
114
+
115
+ #### What gets configured
116
+
117
+ When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, `ensure_cli_env()` sets up:
118
+
119
+ - **Tracing**: `TracerProvider` with OTLP/HTTP span export, W3C `TraceContext` + `Baggage` propagation, and auto-instrumentation for:
120
+ - **Django** (`DjangoInstrumentor`): creates a root span per HTTP request with span names formatted as `{METHOD} {route_template}` (e.g. `GET /api/v1/projects/{pk}/`).
121
+ - **psycopg2** (`Psycopg2Instrumentor`): creates child spans for each SQL query with `db.system`, `db.statement`, and `db.name` attributes. SQL commenter is enabled, adding trace context as SQL comments for database-side correlation.
122
+ - **Redis** (`RedisInstrumentor`): creates child spans for each Redis command with `db.system` and `db.statement` attributes.
123
+ - **Structured log export**: A structlog processor that emits each log event as both an OTLP log record and a span event (when an active span exists).
124
+
125
+ #### Emitting OTel log events via structlog
126
+
127
+ Use structlog as usual. The OTel processor captures events and maps them to OTLP log records:
128
+
129
+ ```python
130
+ import structlog
131
+
132
+ log = structlog.get_logger("code_references")
133
+ log.info("scan-created", code_references__count=3, feature__count=2)
134
+ ```
135
+
136
+ This produces:
137
+
138
+ 1. An **OTLP log record** with:
139
+ - `Body: scan-created`
140
+ - `EventName: code_references.scan_created` (logger name + `inflection.underscore` of the event)
141
+ - `Severity: INFO`
142
+ - `Attributes: code_references.count=3, feature.count=2` (double underscores are converted to dots)
143
+ - W3C Baggage entries from the current OTel context are copied into log attributes (e.g. `amplitude.device_id`, `amplitude.session_id`).
144
+
145
+ 2. A **span event** on the active span (if one exists) with the same name and attributes. This makes structlog events visible in trace backends (e.g. SigNoz's "Events" tab) without requiring separate log correlation. When no span is active (e.g. during startup or management commands), only the OTLP log record is emitted.
146
+
99
147
  ### Metrics
100
148
 
101
149
  Flagsmith uses Prometheus to track performance metrics.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-common"
3
- version = "3.5.0"
3
+ version = "3.6.0"
4
4
  description = "Flagsmith's common library"
5
5
  requires-python = ">=3.11,<4.0"
6
6
  dependencies = []
@@ -16,6 +16,14 @@ optional-dependencies = { test-tools = [
16
16
  "drf-writable-nested",
17
17
  "environs (<15)",
18
18
  "gunicorn (>=19.1)",
19
+ "inflection",
20
+ "opentelemetry-api (>=1.25,<2)",
21
+ "opentelemetry-sdk (>=1.25,<2)",
22
+ "opentelemetry-exporter-otlp-proto-http (>=1.25,<2)",
23
+ "opentelemetry-instrumentation-django (>=0.46b0,<1)",
24
+ "opentelemetry-instrumentation-psycopg2 (>=0.46b0,<1)",
25
+ "opentelemetry-instrumentation-redis (>=0.46b0,<1)",
26
+ "redis (>=5,<6)",
19
27
  "prometheus-client (>=0.0.16)",
20
28
  "psycopg2-binary (>=2.9,<3)",
21
29
  "requests",
@@ -36,6 +36,7 @@ def setup_logging(
36
36
  logging_configuration_file: str | None = None,
37
37
  application_loggers: list[str] | None = None,
38
38
  extra_foreign_processors: list[Processor] | None = None,
39
+ otel_processors: list[Processor] | None = None,
39
40
  ) -> None:
40
41
  """
41
42
  Set up logging for the application.
@@ -91,7 +92,9 @@ def setup_logging(
91
92
  logging.config.dictConfig(dict_config)
92
93
 
93
94
  setup_structlog(
94
- log_format=log_format, extra_foreign_processors=extra_foreign_processors
95
+ log_format=log_format,
96
+ extra_foreign_processors=extra_foreign_processors,
97
+ otel_processors=otel_processors,
95
98
  )
96
99
 
97
100
 
@@ -122,6 +125,7 @@ def map_event_to_json_record(
122
125
  def setup_structlog(
123
126
  log_format: str,
124
127
  extra_foreign_processors: list[Processor] | None = None,
128
+ otel_processors: list[Processor] | None = None,
125
129
  ) -> None:
126
130
  """Configure structlog to route through stdlib logging."""
127
131
 
@@ -172,6 +176,7 @@ def setup_structlog(
172
176
  structlog.processors.format_exc_info,
173
177
  structlog.processors.TimeStamper(fmt="iso"),
174
178
  sentry_processor,
179
+ *(otel_processors or []),
175
180
  structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
176
181
  ],
177
182
  wrapper_class=structlog.stdlib.BoundLogger,
@@ -37,6 +37,35 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
37
37
  """
38
38
  ctx = contextlib.ExitStack()
39
39
 
40
+ # Set up OTel instrumentation (opt-in via OTEL_EXPORTER_OTLP_ENDPOINT).
41
+ otel_processors = None
42
+ otel_endpoint = env.str("OTEL_EXPORTER_OTLP_ENDPOINT", None)
43
+ if otel_endpoint:
44
+ from common.core.otel import (
45
+ add_otel_trace_context,
46
+ build_otel_log_provider,
47
+ build_tracer_provider,
48
+ make_structlog_otel_processor,
49
+ setup_tracing,
50
+ )
51
+
52
+ service_name = env.str("OTEL_SERVICE_NAME", "flagsmith-api")
53
+ log_provider = build_otel_log_provider(
54
+ endpoint=f"{otel_endpoint}/v1/logs",
55
+ service_name=service_name,
56
+ )
57
+ otel_processors = [
58
+ add_otel_trace_context,
59
+ make_structlog_otel_processor(log_provider),
60
+ ]
61
+ tracer_provider = build_tracer_provider(
62
+ endpoint=f"{otel_endpoint}/v1/traces",
63
+ service_name=service_name,
64
+ )
65
+ excluded_urls = env.str("OTEL_TRACING_EXCLUDED_URL_PATHS", None)
66
+ ctx.enter_context(setup_tracing(tracer_provider, excluded_urls=excluded_urls))
67
+ ctx.callback(log_provider.shutdown)
68
+
40
69
  # Set up logging early, before Django settings are loaded.
41
70
  setup_logging(
42
71
  log_level=env.str("LOG_LEVEL", "INFO"),
@@ -48,6 +77,7 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
48
77
  env.list("ACCESS_LOG_EXTRA_ITEMS", []) or None,
49
78
  ),
50
79
  ],
80
+ otel_processors=otel_processors,
51
81
  )
52
82
 
53
83
  # Prometheus multiproc support
@@ -0,0 +1,204 @@
1
+ import contextlib
2
+ import json
3
+ from collections.abc import Generator
4
+ from datetime import datetime, timezone
5
+ from importlib.metadata import version
6
+ from typing import cast
7
+
8
+ import inflection
9
+ import structlog
10
+ from opentelemetry import baggage, trace
11
+ from opentelemetry import context as otel_context
12
+ from opentelemetry._logs import SeverityNumber
13
+ from opentelemetry.baggage.propagation import W3CBaggagePropagator
14
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import (
15
+ OTLPLogExporter,
16
+ )
17
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
18
+ OTLPSpanExporter,
19
+ )
20
+ from opentelemetry.instrumentation.django import DjangoInstrumentor
21
+ from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
22
+ from opentelemetry.instrumentation.redis import RedisInstrumentor
23
+ from opentelemetry.propagate import set_global_textmap
24
+ from opentelemetry.propagators.composite import CompositePropagator
25
+ from opentelemetry.propagators.textmap import TextMapPropagator
26
+ from opentelemetry.sdk._logs import LoggerProvider
27
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
28
+ from opentelemetry.sdk.resources import Resource
29
+ from opentelemetry.sdk.trace import TracerProvider
30
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
31
+ from opentelemetry.trace.propagation.tracecontext import (
32
+ TraceContextTextMapPropagator,
33
+ )
34
+ from opentelemetry.util.types import AnyValue, Attributes
35
+ from structlog.typing import EventDict, Processor
36
+
37
+ _SEVERITY_MAP: dict[str, SeverityNumber] = {
38
+ "debug": SeverityNumber.DEBUG,
39
+ "info": SeverityNumber.INFO,
40
+ "warning": SeverityNumber.WARN,
41
+ "error": SeverityNumber.ERROR,
42
+ "critical": SeverityNumber.FATAL,
43
+ }
44
+
45
+ _RESERVED_KEYS = frozenset(
46
+ [
47
+ "event",
48
+ "level",
49
+ "timestamp",
50
+ "logger",
51
+ "trace_id",
52
+ "span_id",
53
+ ]
54
+ )
55
+
56
+
57
+ def add_otel_trace_context(
58
+ logger: structlog.types.WrappedLogger,
59
+ method_name: str,
60
+ event_dict: EventDict,
61
+ ) -> EventDict:
62
+ """Add ``trace_id`` and ``span_id`` from the active OTel span to the event dict."""
63
+ span = trace.get_current_span()
64
+ ctx = span.get_span_context()
65
+ if ctx and ctx.is_valid:
66
+ event_dict["trace_id"] = f"{ctx.trace_id:032x}"
67
+ event_dict["span_id"] = f"{ctx.span_id:016x}"
68
+ return event_dict
69
+
70
+
71
+ def make_structlog_otel_processor(logger_provider: LoggerProvider) -> Processor:
72
+ """Create a structlog processor that emits log records to OpenTelemetry.
73
+
74
+ Sits in the processor chain *before* the final renderer so that
75
+ only structlog-originated logs reach OTel. Passes the event_dict
76
+ through unchanged so downstream processors (console/JSON renderers)
77
+ still work normally.
78
+
79
+ Pass the returned processor to :func:`~common.core.logging.setup_logging`
80
+ via ``otel_processor``.
81
+ """
82
+ otel_logger = logger_provider.get_logger(__name__, version("flagsmith-common"))
83
+
84
+ def processor(
85
+ logger: structlog.types.WrappedLogger,
86
+ method_name: str,
87
+ event_dict: EventDict,
88
+ ) -> EventDict:
89
+ attributes = map_event_dict_to_otel_attributes(event_dict)
90
+
91
+ # Copy W3C baggage entries into log attributes so downstream
92
+ # exporters can access them.
93
+ ctx = otel_context.get_current()
94
+ for key, value in baggage.get_all(ctx).items():
95
+ attributes[key] = str(value)
96
+
97
+ body = event_dict.get("event", "")
98
+ logger_name = event_dict.get("logger")
99
+ event_name = inflection.underscore(body) if body else "unknown"
100
+ if logger_name:
101
+ event_name = f"{logger_name}.{event_name}"
102
+
103
+ # Some observability platforms don't surface OTel's EventName.
104
+ # Keep a custom attribute for better visibility.
105
+ attributes["flagsmith.event"] = event_name
106
+
107
+ log_level = event_dict.get("level", method_name)
108
+
109
+ otel_logger.emit(
110
+ timestamp=int(datetime.now(timezone.utc).timestamp() * 1e9),
111
+ context=otel_context.get_current(),
112
+ severity_text=log_level,
113
+ severity_number=_SEVERITY_MAP.get(log_level, SeverityNumber.TRACE),
114
+ body=body,
115
+ event_name=event_name,
116
+ attributes=attributes,
117
+ )
118
+
119
+ # Also attach as a span event if there's an active span.
120
+ span = trace.get_current_span()
121
+ if span.is_recording():
122
+ # AnyValue is a superset of AttributeValue at runtime;
123
+ # the cast keeps mypy happy.
124
+ span.add_event(event_name, attributes=cast(Attributes, attributes))
125
+
126
+ return event_dict
127
+
128
+ return processor
129
+
130
+
131
+ def map_event_dict_to_otel_attributes(event_dict: EventDict) -> dict[str, AnyValue]:
132
+ return {
133
+ k.replace("__", "."): map_value_to_otel_value(v)
134
+ for k, v in event_dict.items()
135
+ if k not in _RESERVED_KEYS
136
+ }
137
+
138
+
139
+ def map_value_to_otel_value(value: object) -> str | int | float | bool:
140
+ """Coerce a value to an OTel-attribute-compatible type."""
141
+ if isinstance(value, (bool, str, int, float)):
142
+ return value
143
+ return json.dumps(value, default=str)
144
+
145
+
146
+ def build_otel_log_provider(*, endpoint: str, service_name: str) -> LoggerProvider:
147
+ """Create and configure an OTel LoggerProvider with OTLP/HTTP export."""
148
+ resource = Resource.create({"service.name": service_name})
149
+ provider = LoggerProvider(resource=resource)
150
+ exporter = OTLPLogExporter(endpoint=endpoint)
151
+ provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
152
+ return provider
153
+
154
+
155
+ def build_tracer_provider(*, endpoint: str, service_name: str) -> TracerProvider:
156
+ """Create a TracerProvider with OTLP/HTTP export."""
157
+ resource = Resource.create({"service.name": service_name})
158
+ tracer_provider = TracerProvider(resource=resource)
159
+ span_exporter = OTLPSpanExporter(endpoint=endpoint)
160
+ tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
161
+ return tracer_provider
162
+
163
+
164
+ @contextlib.contextmanager
165
+ def setup_tracing(
166
+ tracer_provider: TracerProvider,
167
+ excluded_urls: str | None = None,
168
+ ) -> Generator[None, None, None]:
169
+ """Set up and tear down OTel distributed tracing with Django instrumentation.
170
+
171
+ Sets the global TracerProvider, configures W3C trace context +
172
+ baggage propagation, and instruments Django so that every request
173
+ creates a span with the incoming trace context.
174
+
175
+ On exit, uninstruments Django and shuts down the tracer provider.
176
+
177
+ Must be called *before* Django's WSGI app is created.
178
+
179
+ Args:
180
+ tracer_provider: The TracerProvider to use.
181
+ excluded_urls: Comma-separated URL paths to exclude from tracing
182
+ (e.g. ``"health/liveness,health/readiness"``). If not provided,
183
+ falls back to the ``OTEL_PYTHON_DJANGO_EXCLUDED_URLS`` env var.
184
+ """
185
+ trace.set_tracer_provider(tracer_provider)
186
+
187
+ propagator: TextMapPropagator = CompositePropagator(
188
+ [
189
+ TraceContextTextMapPropagator(),
190
+ W3CBaggagePropagator(),
191
+ ]
192
+ )
193
+ set_global_textmap(propagator)
194
+
195
+ DjangoInstrumentor().instrument(excluded_urls=excluded_urls)
196
+ Psycopg2Instrumentor().instrument(enable_commenter=True, skip_dep_check=True)
197
+ RedisInstrumentor().instrument()
198
+ try:
199
+ yield
200
+ finally:
201
+ RedisInstrumentor().uninstrument()
202
+ Psycopg2Instrumentor().uninstrument()
203
+ DjangoInstrumentor().uninstrument()
204
+ tracer_provider.shutdown()
@@ -1,6 +1,7 @@
1
1
  from typing import Callable
2
2
 
3
3
  from django.http import HttpRequest, HttpResponse
4
+ from opentelemetry import trace
4
5
 
5
6
  from common.gunicorn.utils import get_route_template, log_extra
6
7
 
@@ -8,7 +9,7 @@ from common.gunicorn.utils import get_route_template, log_extra
8
9
  class RouteLoggerMiddleware:
9
10
  """
10
11
  Make the resolved Django route available to the WSGI server
11
- (e.g. Gunicorn) for logging purposes.
12
+ (e.g. Gunicorn) for logging and tracing purposes.
12
13
  """
13
14
 
14
15
  def __init__(
@@ -21,10 +22,15 @@ class RouteLoggerMiddleware:
21
22
  response = self.get_response(request)
22
23
 
23
24
  if resolver_match := request.resolver_match:
25
+ route_template = get_route_template(resolver_match.route)
24
26
  log_extra(
25
27
  request=request,
26
28
  key="route",
27
- value=get_route_template(resolver_match.route),
29
+ value=route_template,
28
30
  )
31
+ span = trace.get_current_span()
32
+ if span.is_recording():
33
+ span.update_name(f"{request.method} {route_template}")
34
+ span.set_attribute("http.route", route_template)
29
35
 
30
36
  return response