flagsmith-common 3.7.0__tar.gz → 3.8.1__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.7.0 → flagsmith_common-3.8.1}/PKG-INFO +1 -1
  2. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/pyproject.toml +3 -3
  3. flagsmith_common-3.8.1/src/common/core/docgen/events.py +424 -0
  4. flagsmith_common-3.8.1/src/common/core/management/commands/docgen.py +140 -0
  5. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/otel.py +17 -4
  6. flagsmith_common-3.8.1/src/common/core/templates/docgen-events.md +20 -0
  7. flagsmith_common-3.8.1/src/task_processor/py.typed +0 -0
  8. flagsmith_common-3.7.0/src/common/core/management/commands/docgen.py +0 -63
  9. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/LICENSE +0 -0
  10. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/README.md +0 -0
  11. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/__init__.py +0 -0
  12. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/__init__.py +0 -0
  13. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/app.py +0 -0
  14. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/cli/__init__.py +0 -0
  15. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/cli/healthcheck.py +0 -0
  16. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/constants.py +0 -0
  17. {flagsmith_common-3.7.0/src/common/core/management → flagsmith_common-3.8.1/src/common/core/docgen}/__init__.py +0 -0
  18. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/logging.py +0 -0
  19. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/main.py +0 -0
  20. {flagsmith_common-3.7.0/src/common/core/management/commands → flagsmith_common-3.8.1/src/common/core/management}/__init__.py +0 -0
  21. {flagsmith_common-3.7.0/src/common/features → flagsmith_common-3.8.1/src/common/core/management/commands}/__init__.py +0 -0
  22. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/management/commands/start.py +0 -0
  23. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/management/commands/waitfordb.py +0 -0
  24. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/metrics.py +0 -0
  25. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/middleware.py +0 -0
  26. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/sentry.py +0 -0
  27. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/templates/docgen-metrics.md +0 -0
  28. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/urls.py +0 -0
  29. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/utils.py +0 -0
  30. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/core/views.py +0 -0
  31. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/environments/permissions.py +0 -0
  32. {flagsmith_common-3.7.0/src/common/features/multivariate → flagsmith_common-3.8.1/src/common/features}/__init__.py +0 -0
  33. {flagsmith_common-3.7.0/src/common/features/versioning → flagsmith_common-3.8.1/src/common/features/multivariate}/__init__.py +0 -0
  34. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/features/multivariate/serializers.py +0 -0
  35. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/features/serializers.py +0 -0
  36. {flagsmith_common-3.7.0/src/common/gunicorn → flagsmith_common-3.8.1/src/common/features/versioning}/__init__.py +0 -0
  37. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/features/versioning/serializers.py +0 -0
  38. {flagsmith_common-3.7.0/src/common/migrations → flagsmith_common-3.8.1/src/common/gunicorn}/__init__.py +0 -0
  39. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/conf.py +0 -0
  40. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/constants.py +0 -0
  41. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/logging.py +0 -0
  42. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/metrics.py +0 -0
  43. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/metrics_server.py +0 -0
  44. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/middleware.py +0 -0
  45. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/processors.py +0 -0
  46. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/gunicorn/utils.py +0 -0
  47. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/lint_tests.py +0 -0
  48. {flagsmith_common-3.7.0/src/flagsmith_schemas → flagsmith_common-3.8.1/src/common/migrations}/__init__.py +0 -0
  49. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/migrations/helpers/__init__.py +0 -0
  50. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  51. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/organisations/permissions.py +0 -0
  52. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/projects/permissions.py +0 -0
  53. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/prometheus/__init__.py +0 -0
  54. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/prometheus/utils.py +0 -0
  55. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/py.typed +0 -0
  56. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/test_tools/__init__.py +0 -0
  57. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/test_tools/plugin.py +0 -0
  58. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/test_tools/types.py +0 -0
  59. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/test_tools/utils.py +0 -0
  60. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/common/types.py +0 -0
  61. {flagsmith_common-3.7.0/src/task_processor → flagsmith_common-3.8.1/src/flagsmith_schemas}/__init__.py +0 -0
  62. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/api.py +0 -0
  63. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/constants.py +0 -0
  64. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/dynamodb.py +0 -0
  65. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/py.typed +0 -0
  66. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/pydantic_types.py +0 -0
  67. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/types.py +0 -0
  68. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/utils.py +0 -0
  69. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/flagsmith_schemas/validators.py +0 -0
  70. {flagsmith_common-3.7.0/src/task_processor/migrations → flagsmith_common-3.8.1/src/task_processor}/__init__.py +0 -0
  71. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/admin.py +0 -0
  72. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/apps.py +0 -0
  73. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/decorators.py +0 -0
  74. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/exceptions.py +0 -0
  75. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/health.py +0 -0
  76. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/managers.py +0 -0
  77. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/metrics.py +0 -0
  78. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0001_initial.py +0 -0
  79. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  80. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  81. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  82. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  83. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  84. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  85. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  86. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
  87. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0010_task_priority.py +0 -0
  88. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
  89. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  90. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  91. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/0014_add_trace_context.py +0 -0
  92. {flagsmith_common-3.7.0/src/task_processor/migrations/sql → flagsmith_common-3.8.1/src/task_processor/migrations}/__init__.py +0 -0
  93. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  94. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  95. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  96. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  97. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  98. /flagsmith_common-3.7.0/src/task_processor/py.typed → /flagsmith_common-3.8.1/src/task_processor/migrations/sql/__init__.py +0 -0
  99. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/models.py +0 -0
  100. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/monitoring.py +0 -0
  101. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/processor.py +0 -0
  102. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/routers.py +0 -0
  103. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/serializers.py +0 -0
  104. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/task_registry.py +0 -0
  105. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/task_run_method.py +0 -0
  106. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/tasks.py +0 -0
  107. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/threads.py +0 -0
  108. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/types.py +0 -0
  109. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/urls.py +0 -0
  110. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/src/task_processor/utils.py +0 -0
  111. {flagsmith_common-3.7.0 → flagsmith_common-3.8.1}/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.7.0
3
+ Version: 3.8.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-common"
3
- version = "3.7.0"
3
+ version = "3.8.1"
4
4
  description = "Flagsmith's common library"
5
5
  requires-python = ">=3.11,<4.0"
6
6
  dependencies = []
@@ -87,8 +87,8 @@ dev = [
87
87
  "pre-commit",
88
88
  "pydantic>=2.12.5",
89
89
  "pyfakefs (>=5.7.4, <6.0.0)",
90
- "pytest (>=8.3.4, <9.0.0)",
91
- "pytest-asyncio (>=0.25.3, <1.0.0)",
90
+ "pytest (>=9.0.3, <9.1.0)",
91
+ "pytest-asyncio (>=1.0.0, <2.0.0)",
92
92
  "pytest-cov (>=6.0.0, <7.0.0)",
93
93
  "pytest-django (>=4.10.0, <5.0.0)",
94
94
  "pytest-freezegun (>=0.4.2, <1.0.0)",
@@ -0,0 +1,424 @@
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
+ from common.core.otel import get_otel_event_name
8
+
9
+
10
+ class DocgenEventsWarning(UserWarning):
11
+ """Raised by the events scanner when a call site can't be resolved."""
12
+
13
+
14
+ # Emission methods exposed by `structlog.stdlib.BoundLogger` whose first
15
+ # positional argument is the event name. `log` is excluded because its
16
+ # first argument is the level, not the event; `bind`/`unbind`/`new` are
17
+ # not emissions.
18
+ EMIT_METHOD_NAMES = frozenset(
19
+ {
20
+ "debug",
21
+ "info",
22
+ "warning",
23
+ "warn",
24
+ "error",
25
+ "critical",
26
+ "fatal",
27
+ "exception",
28
+ "msg",
29
+ }
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SourceLocation:
35
+ path: Path
36
+ line: int
37
+
38
+
39
+ @dataclass
40
+ class EventEntry:
41
+ name: str
42
+ level: str
43
+ attributes: frozenset[str]
44
+ locations: list[SourceLocation] = field(default_factory=list)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _LoggerScope:
49
+ domain: str
50
+ bound_attrs: frozenset[str]
51
+
52
+
53
+ _EXCLUDED_DIR_NAMES = frozenset({"migrations", "tests"})
54
+ _EXCLUDED_MANAGEMENT_DIR = ("management", "commands")
55
+ _TASK_PROCESSOR_APP_LABEL = "task_processor"
56
+
57
+
58
+ def get_event_entries_from_tree(
59
+ root: Path,
60
+ *,
61
+ app_label: str,
62
+ module_prefix: str,
63
+ ) -> Iterator[EventEntry]:
64
+ """Walk every `*.py` under `root` and yield its scanned event entries.
65
+
66
+ Skips `migrations/`, `tests/`, `conftest.py`, and `test_*.py`. Also skips
67
+ `management/commands/` unless `app_label == "task_processor"`, where the
68
+ runner loop's events are operationally important.
69
+ """
70
+ for file_path in sorted(root.rglob("*.py")):
71
+ if _should_skip(file_path.relative_to(root), app_label=app_label):
72
+ continue
73
+ rel_parts = file_path.relative_to(root).with_suffix("").parts
74
+ module_dotted = ".".join((module_prefix, *rel_parts))
75
+ yield from get_event_entries_from_source(
76
+ file_path.read_text(),
77
+ module_dotted=module_dotted,
78
+ path=file_path,
79
+ )
80
+
81
+
82
+ def _should_skip(relative: Path, *, app_label: str) -> bool:
83
+ parts = relative.parts
84
+ if any(part in _EXCLUDED_DIR_NAMES for part in parts[:-1]):
85
+ return True
86
+ filename = parts[-1]
87
+ if filename == "conftest.py" or filename.startswith("test_"):
88
+ return True
89
+ if (
90
+ len(parts) >= 3
91
+ and parts[0] == _EXCLUDED_MANAGEMENT_DIR[0]
92
+ and parts[1] == _EXCLUDED_MANAGEMENT_DIR[1]
93
+ and app_label != _TASK_PROCESSOR_APP_LABEL
94
+ ):
95
+ return True
96
+ return False
97
+
98
+
99
+ def merge_event_entries(entries: Iterable[EventEntry]) -> list[EventEntry]:
100
+ """Collapse entries sharing an event name: union attributes and locations.
101
+
102
+ Diverging log levels trigger a `DocgenEventsWarning`; the first-seen level
103
+ wins. Output is sorted alphabetically by event name.
104
+ """
105
+ merged: dict[str, EventEntry] = {}
106
+ for entry in entries:
107
+ if existing := merged.get(entry.name):
108
+ if entry.level != existing.level:
109
+ original_location = existing.locations[0]
110
+ new_location = entry.locations[0]
111
+ warnings.warn(
112
+ f"`{entry.name}` is emitted at diverging log levels:"
113
+ f" `{existing.level}` at {original_location.path}:{original_location.line},"
114
+ f" `{entry.level}` at {new_location.path}:{new_location.line}."
115
+ f" Keeping first-seen level `{existing.level}`; reconcile"
116
+ " the emission sites to silence this warning.",
117
+ DocgenEventsWarning,
118
+ stacklevel=2,
119
+ )
120
+ existing.attributes = existing.attributes | entry.attributes
121
+ existing_locations = set(existing.locations)
122
+ for location in entry.locations:
123
+ if location not in existing_locations:
124
+ existing.locations.append(location)
125
+ existing_locations.add(location)
126
+ else:
127
+ merged[entry.name] = EventEntry(
128
+ name=entry.name,
129
+ level=entry.level,
130
+ attributes=entry.attributes,
131
+ locations=list(entry.locations),
132
+ )
133
+ return sorted(merged.values(), key=lambda e: e.name)
134
+
135
+
136
+ def get_event_entries_from_source(
137
+ source: str,
138
+ *,
139
+ module_dotted: str,
140
+ path: Path,
141
+ ) -> Iterator[EventEntry]:
142
+ tree = ast.parse(source)
143
+ visitor = _ScopeVisitor(module_dotted=module_dotted, path=path)
144
+ visitor.visit(tree)
145
+ yield from visitor.entries
146
+
147
+
148
+ class _ScopeVisitor(ast.NodeVisitor):
149
+ """Walks the AST in source order with a stack of logger scopes.
150
+
151
+ Entering a function body pushes a copy of the enclosing scope so
152
+ binds that happen inside the function don't leak to sibling scopes.
153
+ """
154
+
155
+ def __init__(self, *, module_dotted: str, path: Path) -> None:
156
+ self.module_dotted = module_dotted
157
+ self.path = path
158
+ self._scope_stack: list[dict[str, _LoggerScope]] = [{}]
159
+ self._class_stack: list[dict[str, _LoggerScope]] = []
160
+ self._module_classes: dict[str, dict[str, _LoggerScope]] = {}
161
+ self.entries: list[EventEntry] = []
162
+
163
+ @property
164
+ def _scope(self) -> dict[str, _LoggerScope]:
165
+ return self._scope_stack[-1]
166
+
167
+ @property
168
+ def _class_scope(self) -> dict[str, _LoggerScope] | None:
169
+ return self._class_stack[-1] if self._class_stack else None
170
+
171
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
172
+ class_scope: dict[str, _LoggerScope] = {}
173
+ # Own methods take precedence — register them first.
174
+ for stmt in node.body:
175
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
176
+ if accessor := _resolve_method_accessor(stmt, outer_scopes=self._scope):
177
+ class_scope[stmt.name] = accessor
178
+ # Inherit from same-file parents declared earlier (Name-typed bases only).
179
+ for base in node.bases:
180
+ if isinstance(base, ast.Name) and base.id in self._module_classes:
181
+ for method_name, method_scope in self._module_classes[base.id].items():
182
+ class_scope.setdefault(method_name, method_scope)
183
+ self._module_classes[node.name] = class_scope
184
+ self._class_stack.append(class_scope)
185
+ self.generic_visit(node)
186
+ self._class_stack.pop()
187
+
188
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
189
+ self._scope_stack.append(dict(self._scope))
190
+ self.generic_visit(node)
191
+ self._scope_stack.pop()
192
+
193
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
194
+ self._scope_stack.append(dict(self._scope))
195
+ self.generic_visit(node)
196
+ self._scope_stack.pop()
197
+
198
+ def visit_Assign(self, node: ast.Assign) -> None:
199
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
200
+ target_id = node.targets[0].id
201
+ if scope := _resolve_seed(
202
+ node,
203
+ module_dotted=self.module_dotted,
204
+ path=self.path,
205
+ ):
206
+ self._scope[target_id] = scope
207
+ elif scope := _resolve_bind(node, logger_scopes=self._scope):
208
+ self._scope[target_id] = scope
209
+ self.generic_visit(node)
210
+
211
+ def visit_Call(self, node: ast.Call) -> None:
212
+ if entry := _build_entry_from_emit_call(
213
+ node, self._scope, self.path, class_scope=self._class_scope
214
+ ):
215
+ self.entries.append(entry)
216
+ self.generic_visit(node)
217
+
218
+
219
+ def _resolve_seed(
220
+ node: ast.Assign,
221
+ *,
222
+ module_dotted: str,
223
+ path: Path,
224
+ ) -> _LoggerScope | None:
225
+ call = node.value
226
+ if not isinstance(call, ast.Call):
227
+ return None
228
+ func = call.func
229
+ if not isinstance(func, ast.Attribute) or func.attr != "get_logger":
230
+ return None
231
+ if not isinstance(func.value, ast.Name) or func.value.id != "structlog":
232
+ return None
233
+ if not call.args:
234
+ return _LoggerScope(domain="", bound_attrs=frozenset())
235
+ target = node.targets[0]
236
+ assert isinstance(target, ast.Name)
237
+ domain = _resolve_domain(call.args[0], module_dotted=module_dotted)
238
+ if domain is None:
239
+ warnings.warn(
240
+ f"{path}:{node.lineno}: cannot statically resolve logger domain"
241
+ f" for `{target.id}`; skipping its events.",
242
+ DocgenEventsWarning,
243
+ stacklevel=2,
244
+ )
245
+ return None
246
+ return _LoggerScope(domain=domain, bound_attrs=frozenset())
247
+
248
+
249
+ def _resolve_bind(
250
+ node: ast.Assign,
251
+ *,
252
+ logger_scopes: dict[str, _LoggerScope],
253
+ ) -> _LoggerScope | None:
254
+ call = node.value
255
+ if not isinstance(call, ast.Call):
256
+ return None
257
+ func = call.func
258
+ if not isinstance(func, ast.Attribute) or func.attr != "bind":
259
+ return None
260
+ if not isinstance(func.value, ast.Name) or func.value.id not in logger_scopes:
261
+ return None
262
+ parent = logger_scopes[func.value.id]
263
+ new_attrs = _kwargs_as_attributes(call.keywords)
264
+ return _LoggerScope(
265
+ domain=parent.domain,
266
+ bound_attrs=parent.bound_attrs | new_attrs,
267
+ )
268
+
269
+
270
+ def _resolve_domain(node: ast.expr, *, module_dotted: str) -> str | None:
271
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
272
+ return node.value
273
+ if isinstance(node, ast.Name) and node.id == "__name__":
274
+ return module_dotted
275
+ return None
276
+
277
+
278
+ def _kwargs_as_attributes(keywords: list[ast.keyword]) -> frozenset[str]:
279
+ return frozenset(kw.arg.replace("__", ".") for kw in keywords if kw.arg is not None)
280
+
281
+
282
+ def _build_entry_from_emit_call(
283
+ node: ast.Call,
284
+ logger_scopes: dict[str, _LoggerScope],
285
+ path: Path,
286
+ *,
287
+ class_scope: dict[str, _LoggerScope] | None = None,
288
+ ) -> EventEntry | None:
289
+ func = node.func
290
+ if not isinstance(func, ast.Attribute):
291
+ return None
292
+ if func.attr not in EMIT_METHOD_NAMES:
293
+ return None
294
+ scope = _scope_for_emit_target(func.value, logger_scopes, class_scope=class_scope)
295
+ if scope is None:
296
+ if accessor_name := _self_cls_accessor_name(func.value):
297
+ warnings.warn(
298
+ f"{path}:{node.lineno}: cannot resolve"
299
+ f" `{_describe_emit_target(func.value)}.{func.attr}(...)`:"
300
+ f" `{accessor_name}` isn't a tracked accessor on this class"
301
+ " or any same-file parent. Consider inlining the bind at the"
302
+ " call site or moving the accessor into this file.",
303
+ DocgenEventsWarning,
304
+ stacklevel=2,
305
+ )
306
+ return None
307
+ if not node.args:
308
+ return None
309
+ event_arg = node.args[0]
310
+ if not (isinstance(event_arg, ast.Constant) and isinstance(event_arg.value, str)):
311
+ warnings.warn(
312
+ f"{path}:{node.lineno}: cannot statically resolve event name"
313
+ f" for `{_describe_emit_target(func.value)}.{func.attr}(...)`;"
314
+ " skipping. Consider annotating the call site with a"
315
+ " `# docgen: event=<name>` comment so the catalogue can still"
316
+ " pick it up.",
317
+ DocgenEventsWarning,
318
+ stacklevel=2,
319
+ )
320
+ return None
321
+ attributes = scope.bound_attrs | _kwargs_as_attributes(node.keywords)
322
+ return EventEntry(
323
+ name=get_otel_event_name(
324
+ logger_name=scope.domain or None,
325
+ body=event_arg.value,
326
+ ),
327
+ level=func.attr,
328
+ attributes=attributes,
329
+ locations=[SourceLocation(path=path, line=node.lineno)],
330
+ )
331
+
332
+
333
+ def _scope_for_emit_target(
334
+ target: ast.expr,
335
+ logger_scopes: dict[str, _LoggerScope],
336
+ *,
337
+ class_scope: dict[str, _LoggerScope] | None = None,
338
+ ) -> _LoggerScope | None:
339
+ if isinstance(target, ast.Name):
340
+ return logger_scopes.get(target.id)
341
+ if isinstance(target, ast.Attribute):
342
+ # `self.<name>` — method/property accessor on the enclosing class.
343
+ if (
344
+ class_scope is not None
345
+ and isinstance(target.value, ast.Name)
346
+ and target.value.id in _SELF_OR_CLS
347
+ and target.attr in class_scope
348
+ ):
349
+ return class_scope[target.attr]
350
+ return None
351
+ if isinstance(target, ast.Call):
352
+ func = target.func
353
+ if not isinstance(func, ast.Attribute):
354
+ return None
355
+ # `self.<name>(...)` — method accessor invocation.
356
+ if (
357
+ class_scope is not None
358
+ and isinstance(func.value, ast.Name)
359
+ and func.value.id in _SELF_OR_CLS
360
+ and func.attr in class_scope
361
+ ):
362
+ return class_scope[func.attr]
363
+ if func.attr != "bind":
364
+ return None
365
+ parent = _scope_for_emit_target(
366
+ func.value, logger_scopes, class_scope=class_scope
367
+ )
368
+ if parent is None:
369
+ return None
370
+ return _LoggerScope(
371
+ domain=parent.domain,
372
+ bound_attrs=parent.bound_attrs | _kwargs_as_attributes(target.keywords),
373
+ )
374
+ return None
375
+
376
+
377
+ _SELF_OR_CLS = frozenset({"self", "cls"})
378
+
379
+
380
+ def _self_cls_accessor_name(target: ast.expr) -> str | None:
381
+ """Name of the accessor in a `self.<X>` / `cls.<X>(...)` emit shape, else None."""
382
+ if isinstance(target, ast.Attribute):
383
+ if isinstance(target.value, ast.Name) and target.value.id in _SELF_OR_CLS:
384
+ return target.attr
385
+ if isinstance(target, ast.Call):
386
+ func = target.func
387
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
388
+ if func.value.id in _SELF_OR_CLS:
389
+ return func.attr
390
+ return None
391
+
392
+
393
+ def _resolve_method_accessor(
394
+ func_def: ast.FunctionDef | ast.AsyncFunctionDef,
395
+ *,
396
+ outer_scopes: dict[str, _LoggerScope],
397
+ ) -> _LoggerScope | None:
398
+ """Return a scope for a method that just returns a bound logger."""
399
+ body = list(func_def.body)
400
+ # Allow a leading docstring.
401
+ if (
402
+ body
403
+ and isinstance(body[0], ast.Expr)
404
+ and isinstance(body[0].value, ast.Constant)
405
+ and isinstance(body[0].value.value, str)
406
+ ):
407
+ body = body[1:]
408
+ if len(body) != 1:
409
+ return None
410
+ stmt = body[0]
411
+ if not isinstance(stmt, ast.Return) or stmt.value is None:
412
+ return None
413
+ return _scope_for_emit_target(stmt.value, outer_scopes)
414
+
415
+
416
+ def _describe_emit_target(target: ast.expr) -> str:
417
+ if isinstance(target, ast.Name):
418
+ return target.id
419
+ if isinstance(target, ast.Attribute):
420
+ return f"{_describe_emit_target(target.value)}.{target.attr}"
421
+ assert isinstance(target, ast.Call)
422
+ func = target.func
423
+ assert isinstance(func, ast.Attribute)
424
+ return f"{_describe_emit_target(func.value)}.{func.attr}(...)"
@@ -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
@@ -54,6 +54,19 @@ _RESERVED_KEYS = frozenset(
54
54
  )
55
55
 
56
56
 
57
+ def get_otel_event_name(*, logger_name: str | None, body: str) -> str:
58
+ """Build the event name that reaches OTel from a structlog `(logger, event)`.
59
+
60
+ The body is normalised via `inflection.underscore` so hyphens and CamelCase
61
+ collapse to snake_case. Empty bodies fall back to ``"unknown"``. The logger
62
+ name, when present, is prefixed verbatim.
63
+ """
64
+ normalised = inflection.underscore(body) if body else "unknown"
65
+ if logger_name:
66
+ return f"{logger_name}.{normalised}"
67
+ return normalised
68
+
69
+
57
70
  def add_otel_trace_context(
58
71
  logger: structlog.types.WrappedLogger,
59
72
  method_name: str,
@@ -95,10 +108,10 @@ def make_structlog_otel_processor(logger_provider: LoggerProvider) -> Processor:
95
108
  attributes[key] = str(value)
96
109
 
97
110
  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}"
111
+ event_name = get_otel_event_name(
112
+ logger_name=event_dict.get("logger"),
113
+ body=body,
114
+ )
102
115
 
103
116
  # Some observability platforms don't surface OTel's EventName.
104
117
  # Keep a custom attribute for better visibility.
@@ -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 %}
File without changes
@@ -1,63 +0,0 @@
1
- from operator import itemgetter
2
- from typing import Any, Callable
3
-
4
- import prometheus_client
5
- from django.core.management import BaseCommand, CommandParser
6
- from django.template.loader import get_template
7
- from django.utils.module_loading import autodiscover_modules
8
- from prometheus_client.metrics import MetricWrapperBase
9
-
10
-
11
- class Command(BaseCommand):
12
- help = "Generate documentation for the Flagsmith codebase."
13
-
14
- def add_arguments(self, parser: CommandParser) -> None:
15
- subparsers = parser.add_subparsers(
16
- title="sub-commands",
17
- required=True,
18
- )
19
-
20
- metric_parser = subparsers.add_parser(
21
- "metrics",
22
- help="Generate metrics documentation.",
23
- )
24
- metric_parser.set_defaults(handle_method=self.handle_metrics)
25
-
26
- def initialise(self) -> None:
27
- from common.gunicorn import metrics # noqa: F401
28
-
29
- autodiscover_modules(
30
- "metrics",
31
- )
32
-
33
- def handle(
34
- self,
35
- *args: Any,
36
- handle_method: Callable[..., None],
37
- **options: Any,
38
- ) -> None:
39
- self.initialise()
40
- handle_method(*args, **options)
41
-
42
- def handle_metrics(self, *args: Any, **options: Any) -> None:
43
- template = get_template("docgen-metrics.md")
44
-
45
- flagsmith_metrics = sorted(
46
- (
47
- {
48
- "name": collector._name,
49
- "documentation": collector._documentation,
50
- "labels": collector._labelnames,
51
- "type": collector._type,
52
- }
53
- for collector in prometheus_client.REGISTRY._collector_to_names
54
- if isinstance(collector, MetricWrapperBase)
55
- ),
56
- key=itemgetter("name"),
57
- )
58
-
59
- self.stdout.write(
60
- template.render(
61
- context={"flagsmith_metrics": flagsmith_metrics},
62
- )
63
- )