flagsmith-common 3.4.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.
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/PKG-INFO +60 -1
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/README.md +48 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/pyproject.toml +12 -1
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/constants.py +2 -0
- flagsmith_common-3.6.0/src/common/core/logging.py +186 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/main.py +50 -3
- flagsmith_common-3.6.0/src/common/core/otel.py +204 -0
- flagsmith_common-3.6.0/src/common/core/sentry.py +22 -0
- flagsmith_common-3.6.0/src/common/gunicorn/logging.py +66 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/gunicorn/metrics.py +1 -6
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/gunicorn/middleware.py +8 -2
- flagsmith_common-3.6.0/src/common/gunicorn/processors.py +74 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/prometheus/utils.py +1 -4
- flagsmith_common-3.4.0/src/common/core/logging.py +0 -24
- flagsmith_common-3.4.0/src/common/gunicorn/logging.py +0 -120
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/LICENSE +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/app.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/cli/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/cli/healthcheck.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/management/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/docgen.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/start.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/management/commands/waitfordb.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/metrics.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/middleware.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/templates/docgen-metrics.md +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/urls.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/utils.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/core/views.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/environments/permissions.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/features/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/features/multivariate/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/features/multivariate/serializers.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/features/serializers.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/features/versioning/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/features/versioning/serializers.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/gunicorn/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/gunicorn/conf.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/gunicorn/constants.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/gunicorn/metrics_server.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/gunicorn/utils.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/lint_tests.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/migrations/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/migrations/helpers/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/organisations/permissions.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/projects/permissions.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/prometheus/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/py.typed +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/test_tools/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/test_tools/plugin.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/test_tools/types.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/test_tools/utils.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/common/types.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/api.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/constants.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/dynamodb.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/py.typed +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/types.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/utils.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/flagsmith_schemas/validators.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/admin.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/apps.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/decorators.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/exceptions.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/health.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/managers.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/metrics.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0001_initial.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/migrations/sql/__init__.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/models.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/monitoring.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/processor.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/py.typed +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/routers.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/serializers.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/task_registry.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/task_run_method.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/tasks.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/threads.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/types.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/urls.py +0 -0
- {flagsmith_common-3.4.0 → flagsmith_common-3.6.0}/src/task_processor/utils.py +0 -0
- {flagsmith_common-3.4.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.
|
|
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,10 +17,21 @@ 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'
|
|
31
|
+
Requires-Dist: sentry-sdk>=2.0.0,<3.0.0 ; extra == 'common-core'
|
|
23
32
|
Requires-Dist: simplejson>=3,<4 ; extra == 'common-core'
|
|
33
|
+
Requires-Dist: structlog>=24.4,<26 ; extra == 'common-core'
|
|
34
|
+
Requires-Dist: typing-extensions ; extra == 'common-core'
|
|
24
35
|
Requires-Dist: simplejson ; extra == 'flagsmith-schemas'
|
|
25
36
|
Requires-Dist: typing-extensions ; extra == 'flagsmith-schemas'
|
|
26
37
|
Requires-Dist: flagsmith-flag-engine>6 ; extra == 'flagsmith-schemas'
|
|
@@ -142,6 +153,54 @@ Use this mark to auto-use the `saas_mode` fixture.
|
|
|
142
153
|
|
|
143
154
|
Use this mark to auto-use the `enterprise_mode` fixture.
|
|
144
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
|
+
|
|
145
204
|
### Metrics
|
|
146
205
|
|
|
147
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.
|
|
3
|
+
version = "3.6.0"
|
|
4
4
|
description = "Flagsmith's common library"
|
|
5
5
|
requires-python = ">=3.11,<4.0"
|
|
6
6
|
dependencies = []
|
|
@@ -16,10 +16,21 @@ 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",
|
|
30
|
+
"sentry-sdk (>=2.0.0,<3.0.0)",
|
|
22
31
|
"simplejson (>=3,<4)",
|
|
32
|
+
"structlog (>=24.4,<26)",
|
|
33
|
+
"typing_extensions",
|
|
23
34
|
], task-processor = [
|
|
24
35
|
"backoff (>=2.2.1,<3.0.0)",
|
|
25
36
|
"django (>4,<6)",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import logging.config
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
import structlog.contextvars
|
|
11
|
+
import structlog.dev
|
|
12
|
+
import structlog.processors
|
|
13
|
+
import structlog.stdlib
|
|
14
|
+
from structlog.typing import EventDict, Processor, WrappedLogger
|
|
15
|
+
from typing_extensions import TypedDict
|
|
16
|
+
|
|
17
|
+
from common.core.constants import LOGGING_DEFAULT_ROOT_LOG_LEVEL
|
|
18
|
+
from common.core.sentry import sentry_processor
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JsonRecord(TypedDict, extra_items=Any, total=False): # type: ignore[call-arg] # TODO https://github.com/python/mypy/issues/18176
|
|
24
|
+
levelname: str
|
|
25
|
+
message: str
|
|
26
|
+
timestamp: str
|
|
27
|
+
logger_name: str
|
|
28
|
+
pid: int | None
|
|
29
|
+
thread_name: str | None
|
|
30
|
+
exc_info: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def setup_logging(
|
|
34
|
+
log_level: str = "INFO",
|
|
35
|
+
log_format: str = "generic",
|
|
36
|
+
logging_configuration_file: str | None = None,
|
|
37
|
+
application_loggers: list[str] | None = None,
|
|
38
|
+
extra_foreign_processors: list[Processor] | None = None,
|
|
39
|
+
otel_processors: list[Processor] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Set up logging for the application.
|
|
43
|
+
|
|
44
|
+
This should be called early, before Django settings are loaded, to ensure
|
|
45
|
+
that all log output is properly formatted from the start.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
log_level: The log level for application code (e.g. "DEBUG", "INFO").
|
|
49
|
+
log_format: Either "generic" or "json".
|
|
50
|
+
logging_configuration_file: Path to a JSON logging config file.
|
|
51
|
+
If provided, this takes precedence over other format options.
|
|
52
|
+
application_loggers: Top-level logger names for application packages.
|
|
53
|
+
These loggers are set to ``log_level`` while the root logger uses
|
|
54
|
+
``max(log_level, WARNING)`` to suppress noise from third-party
|
|
55
|
+
libraries. If ``log_level`` is DEBUG, everything logs at DEBUG.
|
|
56
|
+
extra_foreign_processors: Additional structlog processors to run in
|
|
57
|
+
the ``foreign_pre_chain`` for stdlib log records (e.g. the
|
|
58
|
+
Gunicorn access log field extractor).
|
|
59
|
+
"""
|
|
60
|
+
if logging_configuration_file:
|
|
61
|
+
with open(logging_configuration_file) as f:
|
|
62
|
+
config = json.load(f)
|
|
63
|
+
logging.config.dictConfig(config)
|
|
64
|
+
else:
|
|
65
|
+
log_level_int = logging.getLevelNamesMapping()[log_level.upper()]
|
|
66
|
+
root_level_int = logging.getLevelNamesMapping()[LOGGING_DEFAULT_ROOT_LOG_LEVEL]
|
|
67
|
+
# Suppress third-party noise at WARNING, but if the user requests
|
|
68
|
+
# DEBUG, honour that for the entire process.
|
|
69
|
+
effective_root_level = (
|
|
70
|
+
log_level_int if log_level_int < logging.INFO else root_level_int
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
dict_config: dict[str, Any] = {
|
|
74
|
+
"version": 1,
|
|
75
|
+
"disable_existing_loggers": False,
|
|
76
|
+
"handlers": {
|
|
77
|
+
"console": {
|
|
78
|
+
"class": "logging.StreamHandler",
|
|
79
|
+
"stream": "ext://sys.stdout",
|
|
80
|
+
"level": log_level,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
"root": {
|
|
84
|
+
"level": effective_root_level,
|
|
85
|
+
"handlers": ["console"],
|
|
86
|
+
},
|
|
87
|
+
"loggers": {
|
|
88
|
+
name: {"level": log_level, "handlers": [], "propagate": True}
|
|
89
|
+
for name in application_loggers or []
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
logging.config.dictConfig(dict_config)
|
|
93
|
+
|
|
94
|
+
setup_structlog(
|
|
95
|
+
log_format=log_format,
|
|
96
|
+
extra_foreign_processors=extra_foreign_processors,
|
|
97
|
+
otel_processors=otel_processors,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def map_event_to_json_record(
|
|
102
|
+
logger: WrappedLogger,
|
|
103
|
+
method_name: str,
|
|
104
|
+
event_dict: EventDict,
|
|
105
|
+
) -> EventDict:
|
|
106
|
+
"""Map structlog fields to match :class:`JsonRecord` output schema."""
|
|
107
|
+
# Remove foreign record args injected by pass_foreign_args so they
|
|
108
|
+
# don't leak into the rendered JSON output.
|
|
109
|
+
event_dict.pop("positional_args", None)
|
|
110
|
+
record: JsonRecord = {
|
|
111
|
+
"message": event_dict.pop("event", ""),
|
|
112
|
+
"levelname": event_dict.pop("level", "").upper(),
|
|
113
|
+
"logger_name": event_dict.pop("logger", ""),
|
|
114
|
+
"pid": os.getpid(),
|
|
115
|
+
"thread_name": threading.current_thread().name,
|
|
116
|
+
}
|
|
117
|
+
if "exception" in event_dict:
|
|
118
|
+
record["exc_info"] = event_dict.pop("exception")
|
|
119
|
+
# Merge remaining structlog keys (e.g. extra_key from bind()) with the
|
|
120
|
+
# canonical record so they appear in the JSON output.
|
|
121
|
+
event_dict.update(record)
|
|
122
|
+
return event_dict
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def setup_structlog(
|
|
126
|
+
log_format: str,
|
|
127
|
+
extra_foreign_processors: list[Processor] | None = None,
|
|
128
|
+
otel_processors: list[Processor] | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Configure structlog to route through stdlib logging."""
|
|
131
|
+
|
|
132
|
+
if log_format == "json":
|
|
133
|
+
renderer_processors: list[Processor] = [
|
|
134
|
+
map_event_to_json_record,
|
|
135
|
+
structlog.processors.JSONRenderer(),
|
|
136
|
+
]
|
|
137
|
+
else:
|
|
138
|
+
colors = sys.stdout.isatty() and structlog.dev._has_colors
|
|
139
|
+
renderer_processors = [
|
|
140
|
+
structlog.dev.ConsoleRenderer(colors=colors),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
foreign_pre_chain: list[Processor] = [
|
|
144
|
+
structlog.contextvars.merge_contextvars,
|
|
145
|
+
structlog.stdlib.add_logger_name,
|
|
146
|
+
structlog.stdlib.add_log_level,
|
|
147
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
148
|
+
structlog.processors.format_exc_info,
|
|
149
|
+
structlog.stdlib.ExtraAdder(),
|
|
150
|
+
*(extra_foreign_processors or []),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
formatter = structlog.stdlib.ProcessorFormatter(
|
|
154
|
+
processors=[
|
|
155
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
156
|
+
*renderer_processors,
|
|
157
|
+
],
|
|
158
|
+
foreign_pre_chain=foreign_pre_chain,
|
|
159
|
+
pass_foreign_args=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Replace the formatter on existing root handlers with ProcessorFormatter.
|
|
163
|
+
root = logging.getLogger()
|
|
164
|
+
for handler in root.handlers:
|
|
165
|
+
handler.setFormatter(formatter)
|
|
166
|
+
|
|
167
|
+
structlog.configure(
|
|
168
|
+
processors=[
|
|
169
|
+
structlog.contextvars.merge_contextvars,
|
|
170
|
+
structlog.stdlib.filter_by_level,
|
|
171
|
+
structlog.stdlib.add_logger_name,
|
|
172
|
+
structlog.stdlib.add_log_level,
|
|
173
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
174
|
+
structlog.processors.StackInfoRenderer(),
|
|
175
|
+
structlog.processors.UnicodeDecoder(),
|
|
176
|
+
structlog.processors.format_exc_info,
|
|
177
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
178
|
+
sentry_processor,
|
|
179
|
+
*(otel_processors or []),
|
|
180
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
181
|
+
],
|
|
182
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
183
|
+
context_class=dict,
|
|
184
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
185
|
+
cache_logger_on_first_use=True,
|
|
186
|
+
)
|
|
@@ -8,8 +8,13 @@ from tempfile import mkdtemp
|
|
|
8
8
|
from django.core.management import (
|
|
9
9
|
execute_from_command_line as django_execute_from_command_line,
|
|
10
10
|
)
|
|
11
|
+
from environs import Env
|
|
11
12
|
|
|
12
13
|
from common.core.cli import healthcheck
|
|
14
|
+
from common.core.logging import setup_logging
|
|
15
|
+
from common.gunicorn.processors import make_gunicorn_access_processor
|
|
16
|
+
|
|
17
|
+
env = Env()
|
|
13
18
|
|
|
14
19
|
logger = logging.getLogger(__name__)
|
|
15
20
|
|
|
@@ -32,7 +37,48 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
|
|
|
32
37
|
"""
|
|
33
38
|
ctx = contextlib.ExitStack()
|
|
34
39
|
|
|
35
|
-
#
|
|
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
|
+
|
|
69
|
+
# Set up logging early, before Django settings are loaded.
|
|
70
|
+
setup_logging(
|
|
71
|
+
log_level=env.str("LOG_LEVEL", "INFO"),
|
|
72
|
+
log_format=env.str("LOG_FORMAT", "generic"),
|
|
73
|
+
logging_configuration_file=env.str("LOGGING_CONFIGURATION_FILE", None),
|
|
74
|
+
application_loggers=env.list("APPLICATION_LOGGERS", []) or None,
|
|
75
|
+
extra_foreign_processors=[
|
|
76
|
+
make_gunicorn_access_processor(
|
|
77
|
+
env.list("ACCESS_LOG_EXTRA_ITEMS", []) or None,
|
|
78
|
+
),
|
|
79
|
+
],
|
|
80
|
+
otel_processors=otel_processors,
|
|
81
|
+
)
|
|
36
82
|
|
|
37
83
|
# Prometheus multiproc support
|
|
38
84
|
if not os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
|
|
@@ -67,12 +113,13 @@ def execute_from_command_line(argv: list[str]) -> None:
|
|
|
67
113
|
"checktaskprocessorthreadhealth": healthcheck.main,
|
|
68
114
|
}[subcommand]
|
|
69
115
|
except (IndexError, KeyError):
|
|
70
|
-
|
|
116
|
+
logger.info("Invoking Django")
|
|
71
117
|
else:
|
|
72
|
-
subcommand_main(
|
|
118
|
+
return subcommand_main(
|
|
73
119
|
argv[2:],
|
|
74
120
|
prog=f"{os.path.basename(argv[0])} {subcommand}",
|
|
75
121
|
)
|
|
122
|
+
django_execute_from_command_line(argv)
|
|
76
123
|
|
|
77
124
|
|
|
78
125
|
def main(argv: list[str] = sys.argv) -> None:
|
|
@@ -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()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import sentry_sdk
|
|
2
|
+
from structlog.typing import EventDict, WrappedLogger
|
|
3
|
+
|
|
4
|
+
_SKIP_CONTEXT_FIELDS = frozenset({"event", "level", "timestamp", "_record"})
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def sentry_processor(
|
|
8
|
+
logger: WrappedLogger,
|
|
9
|
+
method_name: str,
|
|
10
|
+
event_dict: EventDict,
|
|
11
|
+
) -> EventDict:
|
|
12
|
+
"""
|
|
13
|
+
Structlog processor that enriches Sentry with structured context and tags.
|
|
14
|
+
|
|
15
|
+
Since structlog routes through stdlib, Sentry's LoggingIntegration
|
|
16
|
+
automatically captures ERROR+ logs as Sentry events. This processor
|
|
17
|
+
adds structured context on top of that.
|
|
18
|
+
"""
|
|
19
|
+
context = {k: v for k, v in event_dict.items() if k not in _SKIP_CONTEXT_FIELDS}
|
|
20
|
+
sentry_sdk.set_context("structlog", context)
|
|
21
|
+
|
|
22
|
+
return event_dict
|