flagsmith-common 3.6.1__tar.gz → 3.8.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 (111) hide show
  1. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/PKG-INFO +4 -2
  2. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/README.md +2 -1
  3. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/pyproject.toml +2 -1
  4. flagsmith_common-3.8.0/src/common/core/docgen/events.py +420 -0
  5. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/main.py +6 -1
  6. flagsmith_common-3.8.0/src/common/core/management/commands/docgen.py +140 -0
  7. flagsmith_common-3.8.0/src/common/core/templates/docgen-events.md +20 -0
  8. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/test_tools/types.py +1 -1
  9. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/decorators.py +5 -1
  10. flagsmith_common-3.8.0/src/task_processor/migrations/0014_add_trace_context.py +23 -0
  11. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/models.py +4 -1
  12. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/processor.py +15 -0
  13. flagsmith_common-3.8.0/src/task_processor/py.typed +0 -0
  14. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/types.py +2 -0
  15. flagsmith_common-3.6.1/src/common/core/management/commands/docgen.py +0 -63
  16. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/LICENSE +0 -0
  17. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/__init__.py +0 -0
  18. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/__init__.py +0 -0
  19. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/app.py +0 -0
  20. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/cli/__init__.py +0 -0
  21. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/cli/healthcheck.py +0 -0
  22. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/constants.py +0 -0
  23. {flagsmith_common-3.6.1/src/common/core/management → flagsmith_common-3.8.0/src/common/core/docgen}/__init__.py +0 -0
  24. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/logging.py +0 -0
  25. {flagsmith_common-3.6.1/src/common/core/management/commands → flagsmith_common-3.8.0/src/common/core/management}/__init__.py +0 -0
  26. {flagsmith_common-3.6.1/src/common/features → flagsmith_common-3.8.0/src/common/core/management/commands}/__init__.py +0 -0
  27. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/management/commands/start.py +0 -0
  28. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/management/commands/waitfordb.py +0 -0
  29. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/metrics.py +0 -0
  30. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/middleware.py +0 -0
  31. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/otel.py +0 -0
  32. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/sentry.py +0 -0
  33. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/templates/docgen-metrics.md +0 -0
  34. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/urls.py +0 -0
  35. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/utils.py +0 -0
  36. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/core/views.py +0 -0
  37. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/environments/permissions.py +0 -0
  38. {flagsmith_common-3.6.1/src/common/features/multivariate → flagsmith_common-3.8.0/src/common/features}/__init__.py +0 -0
  39. {flagsmith_common-3.6.1/src/common/features/versioning → flagsmith_common-3.8.0/src/common/features/multivariate}/__init__.py +0 -0
  40. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/features/multivariate/serializers.py +0 -0
  41. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/features/serializers.py +0 -0
  42. {flagsmith_common-3.6.1/src/common/gunicorn → flagsmith_common-3.8.0/src/common/features/versioning}/__init__.py +0 -0
  43. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/features/versioning/serializers.py +0 -0
  44. {flagsmith_common-3.6.1/src/common/migrations → flagsmith_common-3.8.0/src/common/gunicorn}/__init__.py +0 -0
  45. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/conf.py +0 -0
  46. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/constants.py +0 -0
  47. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/logging.py +0 -0
  48. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/metrics.py +0 -0
  49. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/metrics_server.py +0 -0
  50. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/middleware.py +0 -0
  51. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/processors.py +0 -0
  52. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/gunicorn/utils.py +0 -0
  53. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/lint_tests.py +0 -0
  54. {flagsmith_common-3.6.1/src/flagsmith_schemas → flagsmith_common-3.8.0/src/common/migrations}/__init__.py +0 -0
  55. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/migrations/helpers/__init__.py +0 -0
  56. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  57. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/organisations/permissions.py +0 -0
  58. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/projects/permissions.py +0 -0
  59. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/prometheus/__init__.py +0 -0
  60. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/prometheus/utils.py +0 -0
  61. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/py.typed +0 -0
  62. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/test_tools/__init__.py +0 -0
  63. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/test_tools/plugin.py +0 -0
  64. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/test_tools/utils.py +0 -0
  65. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/common/types.py +0 -0
  66. {flagsmith_common-3.6.1/src/task_processor → flagsmith_common-3.8.0/src/flagsmith_schemas}/__init__.py +0 -0
  67. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/api.py +0 -0
  68. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/constants.py +0 -0
  69. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/dynamodb.py +0 -0
  70. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/py.typed +0 -0
  71. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
  72. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/types.py +0 -0
  73. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/utils.py +0 -0
  74. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/flagsmith_schemas/validators.py +0 -0
  75. {flagsmith_common-3.6.1/src/task_processor/migrations → flagsmith_common-3.8.0/src/task_processor}/__init__.py +0 -0
  76. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/admin.py +0 -0
  77. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/apps.py +0 -0
  78. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/exceptions.py +0 -0
  79. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/health.py +0 -0
  80. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/managers.py +0 -0
  81. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/metrics.py +0 -0
  82. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0001_initial.py +0 -0
  83. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  84. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  85. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  86. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  87. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  88. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  89. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  90. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
  91. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
  92. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
  93. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  94. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  95. {flagsmith_common-3.6.1/src/task_processor/migrations/sql → flagsmith_common-3.8.0/src/task_processor/migrations}/__init__.py +0 -0
  96. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  97. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  98. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  99. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  100. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  101. /flagsmith_common-3.6.1/src/task_processor/py.typed → /flagsmith_common-3.8.0/src/task_processor/migrations/sql/__init__.py +0 -0
  102. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/monitoring.py +0 -0
  103. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/routers.py +0 -0
  104. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/serializers.py +0 -0
  105. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/task_registry.py +0 -0
  106. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/task_run_method.py +0 -0
  107. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/tasks.py +0 -0
  108. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/threads.py +0 -0
  109. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/urls.py +0 -0
  110. {flagsmith_common-3.6.1 → flagsmith_common-3.8.0}/src/task_processor/utils.py +0 -0
  111. {flagsmith_common-3.6.1 → flagsmith_common-3.8.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.6.1
3
+ Version: 3.8.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
@@ -38,6 +38,7 @@ Requires-Dist: flagsmith-flag-engine>6 ; extra == 'flagsmith-schemas'
38
38
  Requires-Dist: backoff>=2.2.1,<3.0.0 ; extra == 'task-processor'
39
39
  Requires-Dist: django>4,<6 ; extra == 'task-processor'
40
40
  Requires-Dist: django-health-check ; extra == 'task-processor'
41
+ Requires-Dist: opentelemetry-api>=1.25,<2 ; extra == 'task-processor'
41
42
  Requires-Dist: prometheus-client>=0.0.16 ; extra == 'task-processor'
42
43
  Requires-Dist: pyfakefs>=5,<6 ; extra == 'test-tools'
43
44
  Requires-Dist: pytest-django>=4,<5 ; extra == 'test-tools'
@@ -164,7 +165,7 @@ OTel instrumentation is opt-in, controlled by environment variables:
164
165
  | Variable | Description | Default |
165
166
  | --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------- |
166
167
  | `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_SERVICE_NAME` | The `service.name` resource attribute. Defaults to `flagsmith-task-processor` when running the task processor. | `flagsmith-api` |
168
169
  | `OTEL_TRACING_EXCLUDED_URL_PATHS` | Comma-separated URL paths to exclude from tracing (e.g. `health/liveness,health/readiness`). | _(none)_ |
169
170
 
170
171
  Standard `OTEL_*` env vars (e.g. `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_EXPORTER_OTLP_HEADERS`) are also respected by the OTel SDK.
@@ -178,6 +179,7 @@ When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, `ensure_cli_env()` sets up:
178
179
  - **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
180
  - **Redis** (`RedisInstrumentor`): creates child spans for each Redis command with `db.system` and `db.statement` attributes.
180
181
  - **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).
182
+ - **Task processor trace propagation**: When a task is enqueued via `TaskHandler.delay()`, the current W3C trace context (including baggage) is serialized into the task's `trace_context` field. When the task processor executes the task, the context is extracted and a child span is created, linking the task execution back to the originating request trace. Span attributes (`task_identifier`, `task_type`, `result`) match the Prometheus metric labels for cross-signal correlation.
181
183
 
182
184
  #### Emitting OTel log events via structlog
183
185
 
@@ -107,7 +107,7 @@ OTel instrumentation is opt-in, controlled by environment variables:
107
107
  | Variable | Description | Default |
108
108
  | --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------- |
109
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` |
110
+ | `OTEL_SERVICE_NAME` | The `service.name` resource attribute. Defaults to `flagsmith-task-processor` when running the task processor. | `flagsmith-api` |
111
111
  | `OTEL_TRACING_EXCLUDED_URL_PATHS` | Comma-separated URL paths to exclude from tracing (e.g. `health/liveness,health/readiness`). | _(none)_ |
112
112
 
113
113
  Standard `OTEL_*` env vars (e.g. `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_EXPORTER_OTLP_HEADERS`) are also respected by the OTel SDK.
@@ -121,6 +121,7 @@ When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, `ensure_cli_env()` sets up:
121
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
122
  - **Redis** (`RedisInstrumentor`): creates child spans for each Redis command with `db.system` and `db.statement` attributes.
123
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
+ - **Task processor trace propagation**: When a task is enqueued via `TaskHandler.delay()`, the current W3C trace context (including baggage) is serialized into the task's `trace_context` field. When the task processor executes the task, the context is extracted and a child span is created, linking the task execution back to the originating request trace. Span attributes (`task_identifier`, `task_type`, `result`) match the Prometheus metric labels for cross-signal correlation.
124
125
 
125
126
  #### Emitting OTel log events via structlog
126
127
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-common"
3
- version = "3.6.1"
3
+ version = "3.8.0"
4
4
  description = "Flagsmith's common library"
5
5
  requires-python = ">=3.11,<4.0"
6
6
  dependencies = []
@@ -35,6 +35,7 @@ optional-dependencies = { test-tools = [
35
35
  "backoff (>=2.2.1,<3.0.0)",
36
36
  "django (>4,<6)",
37
37
  "django-health-check",
38
+ "opentelemetry-api (>=1.25,<2)",
38
39
  "prometheus-client (>=0.0.16)",
39
40
  ], flagsmith-schemas = [
40
41
  "simplejson",
@@ -0,0 +1,420 @@
1
+ import ast
2
+ import warnings
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Iterable, Iterator
6
+
7
+
8
+ class DocgenEventsWarning(UserWarning):
9
+ """Raised by the events scanner when a call site can't be resolved."""
10
+
11
+
12
+ # Emission methods exposed by `structlog.stdlib.BoundLogger` whose first
13
+ # positional argument is the event name. `log` is excluded because its
14
+ # first argument is the level, not the event; `bind`/`unbind`/`new` are
15
+ # not emissions.
16
+ EMIT_METHOD_NAMES = frozenset(
17
+ {
18
+ "debug",
19
+ "info",
20
+ "warning",
21
+ "warn",
22
+ "error",
23
+ "critical",
24
+ "fatal",
25
+ "exception",
26
+ "msg",
27
+ }
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SourceLocation:
33
+ path: Path
34
+ line: int
35
+
36
+
37
+ @dataclass
38
+ class EventEntry:
39
+ name: str
40
+ level: str
41
+ attributes: frozenset[str]
42
+ locations: list[SourceLocation] = field(default_factory=list)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _LoggerScope:
47
+ domain: str
48
+ bound_attrs: frozenset[str]
49
+
50
+
51
+ _EXCLUDED_DIR_NAMES = frozenset({"migrations", "tests"})
52
+ _EXCLUDED_MANAGEMENT_DIR = ("management", "commands")
53
+ _TASK_PROCESSOR_APP_LABEL = "task_processor"
54
+
55
+
56
+ def get_event_entries_from_tree(
57
+ root: Path,
58
+ *,
59
+ app_label: str,
60
+ module_prefix: str,
61
+ ) -> Iterator[EventEntry]:
62
+ """Walk every `*.py` under `root` and yield its scanned event entries.
63
+
64
+ Skips `migrations/`, `tests/`, `conftest.py`, and `test_*.py`. Also skips
65
+ `management/commands/` unless `app_label == "task_processor"`, where the
66
+ runner loop's events are operationally important.
67
+ """
68
+ for file_path in sorted(root.rglob("*.py")):
69
+ if _should_skip(file_path.relative_to(root), app_label=app_label):
70
+ continue
71
+ rel_parts = file_path.relative_to(root).with_suffix("").parts
72
+ module_dotted = ".".join((module_prefix, *rel_parts))
73
+ yield from get_event_entries_from_source(
74
+ file_path.read_text(),
75
+ module_dotted=module_dotted,
76
+ path=file_path,
77
+ )
78
+
79
+
80
+ def _should_skip(relative: Path, *, app_label: str) -> bool:
81
+ parts = relative.parts
82
+ if any(part in _EXCLUDED_DIR_NAMES for part in parts[:-1]):
83
+ return True
84
+ filename = parts[-1]
85
+ if filename == "conftest.py" or filename.startswith("test_"):
86
+ return True
87
+ if (
88
+ len(parts) >= 3
89
+ and parts[0] == _EXCLUDED_MANAGEMENT_DIR[0]
90
+ and parts[1] == _EXCLUDED_MANAGEMENT_DIR[1]
91
+ and app_label != _TASK_PROCESSOR_APP_LABEL
92
+ ):
93
+ return True
94
+ return False
95
+
96
+
97
+ def merge_event_entries(entries: Iterable[EventEntry]) -> list[EventEntry]:
98
+ """Collapse entries sharing an event name: union attributes and locations.
99
+
100
+ Diverging log levels trigger a `DocgenEventsWarning`; the first-seen level
101
+ wins. Output is sorted alphabetically by event name.
102
+ """
103
+ merged: dict[str, EventEntry] = {}
104
+ for entry in entries:
105
+ if existing := merged.get(entry.name):
106
+ if entry.level != existing.level:
107
+ original_location = existing.locations[0]
108
+ new_location = entry.locations[0]
109
+ warnings.warn(
110
+ f"`{entry.name}` is emitted at diverging log levels:"
111
+ f" `{existing.level}` at {original_location.path}:{original_location.line},"
112
+ f" `{entry.level}` at {new_location.path}:{new_location.line}."
113
+ f" Keeping first-seen level `{existing.level}`; reconcile"
114
+ " the emission sites to silence this warning.",
115
+ DocgenEventsWarning,
116
+ stacklevel=2,
117
+ )
118
+ existing.attributes = existing.attributes | entry.attributes
119
+ existing_locations = set(existing.locations)
120
+ for location in entry.locations:
121
+ if location not in existing_locations:
122
+ existing.locations.append(location)
123
+ existing_locations.add(location)
124
+ else:
125
+ merged[entry.name] = EventEntry(
126
+ name=entry.name,
127
+ level=entry.level,
128
+ attributes=entry.attributes,
129
+ locations=list(entry.locations),
130
+ )
131
+ return sorted(merged.values(), key=lambda e: e.name)
132
+
133
+
134
+ def get_event_entries_from_source(
135
+ source: str,
136
+ *,
137
+ module_dotted: str,
138
+ path: Path,
139
+ ) -> Iterator[EventEntry]:
140
+ tree = ast.parse(source)
141
+ visitor = _ScopeVisitor(module_dotted=module_dotted, path=path)
142
+ visitor.visit(tree)
143
+ yield from visitor.entries
144
+
145
+
146
+ class _ScopeVisitor(ast.NodeVisitor):
147
+ """Walks the AST in source order with a stack of logger scopes.
148
+
149
+ Entering a function body pushes a copy of the enclosing scope so
150
+ binds that happen inside the function don't leak to sibling scopes.
151
+ """
152
+
153
+ def __init__(self, *, module_dotted: str, path: Path) -> None:
154
+ self.module_dotted = module_dotted
155
+ self.path = path
156
+ self._scope_stack: list[dict[str, _LoggerScope]] = [{}]
157
+ self._class_stack: list[dict[str, _LoggerScope]] = []
158
+ self._module_classes: dict[str, dict[str, _LoggerScope]] = {}
159
+ self.entries: list[EventEntry] = []
160
+
161
+ @property
162
+ def _scope(self) -> dict[str, _LoggerScope]:
163
+ return self._scope_stack[-1]
164
+
165
+ @property
166
+ def _class_scope(self) -> dict[str, _LoggerScope] | None:
167
+ return self._class_stack[-1] if self._class_stack else None
168
+
169
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
170
+ class_scope: dict[str, _LoggerScope] = {}
171
+ # Own methods take precedence — register them first.
172
+ for stmt in node.body:
173
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
174
+ if accessor := _resolve_method_accessor(stmt, outer_scopes=self._scope):
175
+ class_scope[stmt.name] = accessor
176
+ # Inherit from same-file parents declared earlier (Name-typed bases only).
177
+ for base in node.bases:
178
+ if isinstance(base, ast.Name) and base.id in self._module_classes:
179
+ for method_name, method_scope in self._module_classes[base.id].items():
180
+ class_scope.setdefault(method_name, method_scope)
181
+ self._module_classes[node.name] = class_scope
182
+ self._class_stack.append(class_scope)
183
+ self.generic_visit(node)
184
+ self._class_stack.pop()
185
+
186
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
187
+ self._scope_stack.append(dict(self._scope))
188
+ self.generic_visit(node)
189
+ self._scope_stack.pop()
190
+
191
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
192
+ self._scope_stack.append(dict(self._scope))
193
+ self.generic_visit(node)
194
+ self._scope_stack.pop()
195
+
196
+ def visit_Assign(self, node: ast.Assign) -> None:
197
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
198
+ target_id = node.targets[0].id
199
+ if scope := _resolve_seed(
200
+ node,
201
+ module_dotted=self.module_dotted,
202
+ path=self.path,
203
+ ):
204
+ self._scope[target_id] = scope
205
+ elif scope := _resolve_bind(node, logger_scopes=self._scope):
206
+ self._scope[target_id] = scope
207
+ self.generic_visit(node)
208
+
209
+ def visit_Call(self, node: ast.Call) -> None:
210
+ if entry := _build_entry_from_emit_call(
211
+ node, self._scope, self.path, class_scope=self._class_scope
212
+ ):
213
+ self.entries.append(entry)
214
+ self.generic_visit(node)
215
+
216
+
217
+ def _resolve_seed(
218
+ node: ast.Assign,
219
+ *,
220
+ module_dotted: str,
221
+ path: Path,
222
+ ) -> _LoggerScope | None:
223
+ call = node.value
224
+ if not isinstance(call, ast.Call):
225
+ return None
226
+ func = call.func
227
+ if not isinstance(func, ast.Attribute) or func.attr != "get_logger":
228
+ return None
229
+ if not isinstance(func.value, ast.Name) or func.value.id != "structlog":
230
+ return None
231
+ if not call.args:
232
+ return _LoggerScope(domain="", bound_attrs=frozenset())
233
+ target = node.targets[0]
234
+ assert isinstance(target, ast.Name)
235
+ domain = _resolve_domain(call.args[0], module_dotted=module_dotted)
236
+ if domain is None:
237
+ warnings.warn(
238
+ f"{path}:{node.lineno}: cannot statically resolve logger domain"
239
+ f" for `{target.id}`; skipping its events.",
240
+ DocgenEventsWarning,
241
+ stacklevel=2,
242
+ )
243
+ return None
244
+ return _LoggerScope(domain=domain, bound_attrs=frozenset())
245
+
246
+
247
+ def _resolve_bind(
248
+ node: ast.Assign,
249
+ *,
250
+ logger_scopes: dict[str, _LoggerScope],
251
+ ) -> _LoggerScope | None:
252
+ call = node.value
253
+ if not isinstance(call, ast.Call):
254
+ return None
255
+ func = call.func
256
+ if not isinstance(func, ast.Attribute) or func.attr != "bind":
257
+ return None
258
+ if not isinstance(func.value, ast.Name) or func.value.id not in logger_scopes:
259
+ return None
260
+ parent = logger_scopes[func.value.id]
261
+ new_attrs = _kwargs_as_attributes(call.keywords)
262
+ return _LoggerScope(
263
+ domain=parent.domain,
264
+ bound_attrs=parent.bound_attrs | new_attrs,
265
+ )
266
+
267
+
268
+ def _resolve_domain(node: ast.expr, *, module_dotted: str) -> str | None:
269
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
270
+ return node.value
271
+ if isinstance(node, ast.Name) and node.id == "__name__":
272
+ return module_dotted
273
+ return None
274
+
275
+
276
+ def _kwargs_as_attributes(keywords: list[ast.keyword]) -> frozenset[str]:
277
+ return frozenset(kw.arg.replace("__", ".") for kw in keywords if kw.arg is not None)
278
+
279
+
280
+ def _build_entry_from_emit_call(
281
+ node: ast.Call,
282
+ logger_scopes: dict[str, _LoggerScope],
283
+ path: Path,
284
+ *,
285
+ class_scope: dict[str, _LoggerScope] | None = None,
286
+ ) -> EventEntry | None:
287
+ func = node.func
288
+ if not isinstance(func, ast.Attribute):
289
+ return None
290
+ if func.attr not in EMIT_METHOD_NAMES:
291
+ return None
292
+ scope = _scope_for_emit_target(func.value, logger_scopes, class_scope=class_scope)
293
+ if scope is None:
294
+ if accessor_name := _self_cls_accessor_name(func.value):
295
+ warnings.warn(
296
+ f"{path}:{node.lineno}: cannot resolve"
297
+ f" `{_describe_emit_target(func.value)}.{func.attr}(...)`:"
298
+ f" `{accessor_name}` isn't a tracked accessor on this class"
299
+ " or any same-file parent. Consider inlining the bind at the"
300
+ " call site or moving the accessor into this file.",
301
+ DocgenEventsWarning,
302
+ stacklevel=2,
303
+ )
304
+ return None
305
+ if not node.args:
306
+ return None
307
+ event_arg = node.args[0]
308
+ if not (isinstance(event_arg, ast.Constant) and isinstance(event_arg.value, str)):
309
+ warnings.warn(
310
+ f"{path}:{node.lineno}: cannot statically resolve event name"
311
+ f" for `{_describe_emit_target(func.value)}.{func.attr}(...)`;"
312
+ " skipping. Consider annotating the call site with a"
313
+ " `# docgen: event=<name>` comment so the catalogue can still"
314
+ " pick it up.",
315
+ DocgenEventsWarning,
316
+ stacklevel=2,
317
+ )
318
+ return None
319
+ attributes = scope.bound_attrs | _kwargs_as_attributes(node.keywords)
320
+ name = f"{scope.domain}.{event_arg.value}" if scope.domain else event_arg.value
321
+ return EventEntry(
322
+ name=name,
323
+ level=func.attr,
324
+ attributes=attributes,
325
+ locations=[SourceLocation(path=path, line=node.lineno)],
326
+ )
327
+
328
+
329
+ def _scope_for_emit_target(
330
+ target: ast.expr,
331
+ logger_scopes: dict[str, _LoggerScope],
332
+ *,
333
+ class_scope: dict[str, _LoggerScope] | None = None,
334
+ ) -> _LoggerScope | None:
335
+ if isinstance(target, ast.Name):
336
+ return logger_scopes.get(target.id)
337
+ if isinstance(target, ast.Attribute):
338
+ # `self.<name>` — method/property accessor on the enclosing class.
339
+ if (
340
+ class_scope is not None
341
+ and isinstance(target.value, ast.Name)
342
+ and target.value.id in _SELF_OR_CLS
343
+ and target.attr in class_scope
344
+ ):
345
+ return class_scope[target.attr]
346
+ return None
347
+ if isinstance(target, ast.Call):
348
+ func = target.func
349
+ if not isinstance(func, ast.Attribute):
350
+ return None
351
+ # `self.<name>(...)` — method accessor invocation.
352
+ if (
353
+ class_scope is not None
354
+ and isinstance(func.value, ast.Name)
355
+ and func.value.id in _SELF_OR_CLS
356
+ and func.attr in class_scope
357
+ ):
358
+ return class_scope[func.attr]
359
+ if func.attr != "bind":
360
+ return None
361
+ parent = _scope_for_emit_target(
362
+ func.value, logger_scopes, class_scope=class_scope
363
+ )
364
+ if parent is None:
365
+ return None
366
+ return _LoggerScope(
367
+ domain=parent.domain,
368
+ bound_attrs=parent.bound_attrs | _kwargs_as_attributes(target.keywords),
369
+ )
370
+ return None
371
+
372
+
373
+ _SELF_OR_CLS = frozenset({"self", "cls"})
374
+
375
+
376
+ def _self_cls_accessor_name(target: ast.expr) -> str | None:
377
+ """Name of the accessor in a `self.<X>` / `cls.<X>(...)` emit shape, else None."""
378
+ if isinstance(target, ast.Attribute):
379
+ if isinstance(target.value, ast.Name) and target.value.id in _SELF_OR_CLS:
380
+ return target.attr
381
+ if isinstance(target, ast.Call):
382
+ func = target.func
383
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
384
+ if func.value.id in _SELF_OR_CLS:
385
+ return func.attr
386
+ return None
387
+
388
+
389
+ def _resolve_method_accessor(
390
+ func_def: ast.FunctionDef | ast.AsyncFunctionDef,
391
+ *,
392
+ outer_scopes: dict[str, _LoggerScope],
393
+ ) -> _LoggerScope | None:
394
+ """Return a scope for a method that just returns a bound logger."""
395
+ body = list(func_def.body)
396
+ # Allow a leading docstring.
397
+ if (
398
+ body
399
+ and isinstance(body[0], ast.Expr)
400
+ and isinstance(body[0].value, ast.Constant)
401
+ and isinstance(body[0].value.value, str)
402
+ ):
403
+ body = body[1:]
404
+ if len(body) != 1:
405
+ return None
406
+ stmt = body[0]
407
+ if not isinstance(stmt, ast.Return) or stmt.value is None:
408
+ return None
409
+ return _scope_for_emit_target(stmt.value, outer_scopes)
410
+
411
+
412
+ def _describe_emit_target(target: ast.expr) -> str:
413
+ if isinstance(target, ast.Name):
414
+ return target.id
415
+ if isinstance(target, ast.Attribute):
416
+ return f"{_describe_emit_target(target.value)}.{target.attr}"
417
+ assert isinstance(target, ast.Call)
418
+ func = target.func
419
+ assert isinstance(func, ast.Attribute)
420
+ return f"{_describe_emit_target(func.value)}.{func.attr}(...)"
@@ -66,7 +66,12 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
66
66
  setup_tracing,
67
67
  )
68
68
 
69
- service_name = env.str("OTEL_SERVICE_NAME", "flagsmith-api")
69
+ default_service_name = (
70
+ "flagsmith-task-processor"
71
+ if "task-processor" in sys.argv
72
+ else "flagsmith-api"
73
+ )
74
+ service_name = env.str("OTEL_SERVICE_NAME", default_service_name)
70
75
  log_provider = build_otel_log_provider(
71
76
  endpoint=f"{otel_endpoint}/v1/logs",
72
77
  service_name=service_name,
@@ -0,0 +1,140 @@
1
+ import subprocess
2
+ from operator import itemgetter
3
+ from pathlib import Path
4
+ from typing import Any, Callable
5
+
6
+ import prometheus_client
7
+ from django.apps import apps
8
+ from django.core.management import BaseCommand, CommandParser
9
+ from django.template.loader import get_template
10
+ from django.utils.module_loading import autodiscover_modules
11
+ from prometheus_client.metrics import MetricWrapperBase
12
+
13
+ from common.core.docgen.events import (
14
+ EventEntry,
15
+ get_event_entries_from_tree,
16
+ merge_event_entries,
17
+ )
18
+
19
+
20
+ class Command(BaseCommand):
21
+ help = "Generate documentation for the Flagsmith codebase."
22
+
23
+ def add_arguments(self, parser: CommandParser) -> None:
24
+ subparsers = parser.add_subparsers(
25
+ title="sub-commands",
26
+ required=True,
27
+ )
28
+
29
+ metric_parser = subparsers.add_parser(
30
+ "metrics",
31
+ help="Generate metrics documentation.",
32
+ )
33
+ metric_parser.set_defaults(handle_method=self.handle_metrics)
34
+
35
+ events_parser = subparsers.add_parser(
36
+ "events",
37
+ help="Generate structlog events documentation.",
38
+ )
39
+ events_parser.set_defaults(handle_method=self.handle_events)
40
+
41
+ def initialise(self) -> None:
42
+ from common.gunicorn import metrics # noqa: F401
43
+
44
+ autodiscover_modules(
45
+ "metrics",
46
+ )
47
+
48
+ def handle(
49
+ self,
50
+ *args: Any,
51
+ handle_method: Callable[..., None],
52
+ **options: Any,
53
+ ) -> None:
54
+ self.initialise()
55
+ handle_method(*args, **options)
56
+
57
+ def handle_metrics(self, *args: Any, **options: Any) -> None:
58
+ template = get_template("docgen-metrics.md")
59
+
60
+ flagsmith_metrics = sorted(
61
+ (
62
+ {
63
+ "name": collector._name,
64
+ "documentation": collector._documentation,
65
+ "labels": collector._labelnames,
66
+ "type": collector._type,
67
+ }
68
+ for collector in prometheus_client.REGISTRY._collector_to_names
69
+ if isinstance(collector, MetricWrapperBase)
70
+ ),
71
+ key=itemgetter("name"),
72
+ )
73
+
74
+ self.stdout.write(
75
+ template.render(
76
+ context={"flagsmith_metrics": flagsmith_metrics},
77
+ )
78
+ )
79
+
80
+ def handle_events(self, *args: Any, **options: Any) -> None:
81
+ template = get_template("docgen-events.md")
82
+
83
+ repo_root = _get_repo_root()
84
+ entries: list[EventEntry] = []
85
+ for app_config in apps.get_app_configs():
86
+ entries.extend(
87
+ get_event_entries_from_tree(
88
+ Path(app_config.path),
89
+ app_label=app_config.label,
90
+ module_prefix=app_config.name,
91
+ )
92
+ )
93
+ merged = merge_event_entries(entries)
94
+
95
+ flagsmith_events = [
96
+ {
97
+ "name": entry.name,
98
+ "level": entry.level,
99
+ "locations": [
100
+ {
101
+ "path": _relative_if_under(location.path, repo_root),
102
+ "line": location.line,
103
+ }
104
+ for location in entry.locations
105
+ ],
106
+ "attributes": sorted(entry.attributes),
107
+ }
108
+ for entry in merged
109
+ ]
110
+
111
+ self.stdout.write(
112
+ template.render(
113
+ context={"flagsmith_events": flagsmith_events},
114
+ )
115
+ )
116
+
117
+
118
+ def _get_repo_root() -> Path:
119
+ """Resolve the git repo root for emitted source paths.
120
+
121
+ Falls back to the current working directory when git isn't available or
122
+ the CWD isn't inside a repo.
123
+ """
124
+ try:
125
+ result = subprocess.run(
126
+ ["git", "rev-parse", "--show-toplevel"],
127
+ capture_output=True,
128
+ text=True,
129
+ check=True,
130
+ )
131
+ except (subprocess.CalledProcessError, FileNotFoundError):
132
+ return Path.cwd()
133
+ return Path(result.stdout.strip())
134
+
135
+
136
+ def _relative_if_under(path: Path, base: Path) -> Path:
137
+ try:
138
+ return path.relative_to(base)
139
+ except ValueError:
140
+ return path
@@ -0,0 +1,20 @@
1
+ ---
2
+ title: Events
3
+ sidebar_label: Events
4
+ sidebar_position: 30
5
+ ---
6
+
7
+ Flagsmith backend emits [OpenTelemetry events](https://opentelemetry.io/docs/specs/otel/logs/data-model/#events)
8
+ that can be ingested to downstream observability systems and/or a data warehouse of your choice via OTLP.
9
+ To learn how to configure this, see [OpenTelemetry](deployment-self-hosting/scaling-and-performance/opentelemetry).
10
+
11
+ ## Event catalogue
12
+ {% for event in flagsmith_events %}
13
+ ### `{{ event.name }}`
14
+
15
+ Logged at `{{ event.level }}` from:
16
+ {% for location in event.locations %} - `{{ location.path }}:{{ location.line }}`
17
+ {% endfor %}
18
+ Attributes:
19
+ {% for attr in event.attributes %} - `{{ attr }}`
20
+ {% endfor %}{% endfor %}
@@ -20,7 +20,7 @@ class AssertMetricFixture(Protocol):
20
20
  class RunTasksFixture(Protocol):
21
21
  def __call__(
22
22
  self,
23
- num_tasks: int,
23
+ num_tasks: int = 1,
24
24
  ) -> "list[TaskRun]": ...
25
25
 
26
26