flagsmith-common 2.2.4__py3-none-any.whl

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 (92) hide show
  1. common/__init__.py +0 -0
  2. common/core/__init__.py +6 -0
  3. common/core/app.py +6 -0
  4. common/core/cli/__init__.py +0 -0
  5. common/core/cli/healthcheck.py +120 -0
  6. common/core/logging.py +24 -0
  7. common/core/main.py +105 -0
  8. common/core/management/__init__.py +0 -0
  9. common/core/management/commands/__init__.py +0 -0
  10. common/core/management/commands/docgen.py +63 -0
  11. common/core/management/commands/start.py +61 -0
  12. common/core/management/commands/waitfordb.py +87 -0
  13. common/core/metrics.py +25 -0
  14. common/core/middleware.py +22 -0
  15. common/core/templates/docgen-metrics.md +22 -0
  16. common/core/urls.py +17 -0
  17. common/core/utils.py +239 -0
  18. common/core/views.py +27 -0
  19. common/environments/permissions.py +15 -0
  20. common/features/__init__.py +0 -0
  21. common/features/multivariate/__init__.py +0 -0
  22. common/features/multivariate/serializers.py +19 -0
  23. common/features/serializers.py +68 -0
  24. common/features/versioning/__init__.py +0 -0
  25. common/features/versioning/serializers.py +13 -0
  26. common/gunicorn/__init__.py +0 -0
  27. common/gunicorn/conf.py +18 -0
  28. common/gunicorn/constants.py +23 -0
  29. common/gunicorn/logging.py +120 -0
  30. common/gunicorn/metrics.py +26 -0
  31. common/gunicorn/middleware.py +30 -0
  32. common/gunicorn/utils.py +104 -0
  33. common/migrations/__init__.py +0 -0
  34. common/migrations/helpers/__init__.py +9 -0
  35. common/migrations/helpers/postgres_helpers.py +41 -0
  36. common/organisations/permissions.py +10 -0
  37. common/projects/permissions.py +40 -0
  38. common/prometheus/__init__.py +3 -0
  39. common/prometheus/utils.py +38 -0
  40. common/py.typed +0 -0
  41. common/test_tools/__init__.py +11 -0
  42. common/test_tools/plugin.py +139 -0
  43. common/test_tools/types.py +56 -0
  44. common/test_tools/utils.py +11 -0
  45. common/types.py +45 -0
  46. flagsmith_common-2.2.4.dist-info/METADATA +196 -0
  47. flagsmith_common-2.2.4.dist-info/RECORD +92 -0
  48. flagsmith_common-2.2.4.dist-info/WHEEL +4 -0
  49. flagsmith_common-2.2.4.dist-info/entry_points.txt +6 -0
  50. flagsmith_common-2.2.4.dist-info/licenses/LICENSE +28 -0
  51. task_processor/__init__.py +0 -0
  52. task_processor/admin.py +38 -0
  53. task_processor/apps.py +47 -0
  54. task_processor/decorators.py +209 -0
  55. task_processor/exceptions.py +28 -0
  56. task_processor/health.py +44 -0
  57. task_processor/managers.py +18 -0
  58. task_processor/metrics.py +22 -0
  59. task_processor/migrations/0001_initial.py +44 -0
  60. task_processor/migrations/0002_healthcheckmodel.py +21 -0
  61. task_processor/migrations/0003_add_completed_to_task.py +22 -0
  62. task_processor/migrations/0004_recreate_task_indexes.py +43 -0
  63. task_processor/migrations/0005_update_conditional_index_conditions.py +45 -0
  64. task_processor/migrations/0006_auto_20230221_0802.py +45 -0
  65. task_processor/migrations/0007_add_is_locked.py +23 -0
  66. task_processor/migrations/0008_add_get_task_to_process_function.py +31 -0
  67. task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +18 -0
  68. task_processor/migrations/0010_task_priority.py +27 -0
  69. task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +27 -0
  70. task_processor/migrations/0012_add_locked_at_and_timeout.py +40 -0
  71. task_processor/migrations/0013_add_last_picked_at.py +34 -0
  72. task_processor/migrations/__init__.py +0 -0
  73. task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +30 -0
  74. task_processor/migrations/sql/0008_get_tasks_to_process.sql +30 -0
  75. task_processor/migrations/sql/0011_get_tasks_to_process.sql +30 -0
  76. task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +33 -0
  77. task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +33 -0
  78. task_processor/migrations/sql/__init__.py +0 -0
  79. task_processor/models.py +237 -0
  80. task_processor/monitoring.py +12 -0
  81. task_processor/processor.py +202 -0
  82. task_processor/py.typed +0 -0
  83. task_processor/routers.py +55 -0
  84. task_processor/serializers.py +7 -0
  85. task_processor/task_registry.py +90 -0
  86. task_processor/task_run_method.py +7 -0
  87. task_processor/tasks.py +71 -0
  88. task_processor/threads.py +128 -0
  89. task_processor/types.py +18 -0
  90. task_processor/urls.py +5 -0
  91. task_processor/utils.py +71 -0
  92. task_processor/views.py +20 -0
common/core/utils.py ADDED
@@ -0,0 +1,239 @@
1
+ import json
2
+ import logging
3
+ import pathlib
4
+ import random
5
+ import sys
6
+ import tempfile
7
+ from functools import lru_cache
8
+ from itertools import cycle
9
+ from typing import (
10
+ TYPE_CHECKING,
11
+ Iterator,
12
+ Literal,
13
+ NotRequired,
14
+ TypedDict,
15
+ TypeVar,
16
+ get_args,
17
+ )
18
+
19
+ from django.conf import settings
20
+ from django.contrib.auth import get_user_model
21
+ from django.db import connections
22
+ from django.db.utils import OperationalError
23
+
24
+ from common.core import ReplicaReadStrategy
25
+
26
+ if TYPE_CHECKING:
27
+ from django.contrib.auth.models import AbstractBaseUser
28
+ from django.db.models.base import Model
29
+ from django.db.models.manager import Manager
30
+
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ UNKNOWN = "unknown"
35
+ VERSIONS_INFO_FILE_LOCATION = ".versions.json"
36
+
37
+ ManagerType = TypeVar("ManagerType", bound="Manager[Model]")
38
+
39
+ ReplicaNamePrefix = Literal["replica_", "cross_region_replica_"]
40
+ _replica_sequential_names_by_prefix: dict[ReplicaNamePrefix, Iterator[str]] = {}
41
+
42
+
43
+ class SelfHostedData(TypedDict):
44
+ has_users: bool
45
+ has_logins: bool
46
+
47
+
48
+ VersionManifest = TypedDict(
49
+ "VersionManifest",
50
+ {
51
+ ".": str, # This key is used to store the version of the package itself
52
+ },
53
+ )
54
+
55
+
56
+ class VersionInfo(TypedDict):
57
+ ci_commit_sha: str
58
+ image_tag: str
59
+ has_email_provider: bool
60
+ is_enterprise: bool
61
+ is_saas: bool
62
+ self_hosted_data: SelfHostedData | None
63
+ package_versions: NotRequired[VersionManifest]
64
+
65
+
66
+ @lru_cache()
67
+ def is_enterprise() -> bool:
68
+ return pathlib.Path("./ENTERPRISE_VERSION").exists()
69
+
70
+
71
+ @lru_cache()
72
+ def is_saas() -> bool:
73
+ return pathlib.Path("./SAAS_DEPLOYMENT").exists()
74
+
75
+
76
+ def is_oss() -> bool:
77
+ return not (is_enterprise() or is_saas())
78
+
79
+
80
+ @lru_cache()
81
+ def has_email_provider() -> bool:
82
+ match settings.EMAIL_BACKEND:
83
+ case "django.core.mail.backends.smtp.EmailBackend":
84
+ return settings.EMAIL_HOST_USER is not None
85
+ case "sgbackend.SendGridBackend":
86
+ return settings.SENDGRID_API_KEY is not None
87
+ case "django_ses.SESBackend":
88
+ return settings.AWS_SES_REGION_ENDPOINT is not None
89
+ case _:
90
+ return False
91
+
92
+
93
+ def get_version_info() -> VersionInfo:
94
+ """Returns the version information for the current deployment"""
95
+ _is_saas = is_saas()
96
+ version_json: VersionInfo = {
97
+ "ci_commit_sha": get_file_contents("./CI_COMMIT_SHA") or UNKNOWN,
98
+ "image_tag": UNKNOWN,
99
+ "has_email_provider": has_email_provider(),
100
+ "is_enterprise": is_enterprise(),
101
+ "is_saas": _is_saas,
102
+ "self_hosted_data": None,
103
+ }
104
+
105
+ manifest_versions = get_versions_from_manifest()
106
+ version_json["package_versions"] = manifest_versions
107
+ version_json["image_tag"] = manifest_versions["."]
108
+
109
+ if not _is_saas:
110
+ user_objects: "Manager[AbstractBaseUser]" = getattr(get_user_model(), "objects")
111
+
112
+ version_json["self_hosted_data"] = {
113
+ "has_users": user_objects.exists(),
114
+ "has_logins": user_objects.filter(last_login__isnull=False).exists(),
115
+ }
116
+
117
+ return version_json
118
+
119
+
120
+ def get_version() -> str:
121
+ """Return the version number of the current deployment"""
122
+ manifest_versions = get_versions_from_manifest()
123
+ return manifest_versions.get(".", UNKNOWN)
124
+
125
+
126
+ @lru_cache()
127
+ def get_versions_from_manifest() -> VersionManifest:
128
+ """Read the version info from the manifest file"""
129
+ raw_content = get_file_contents(VERSIONS_INFO_FILE_LOCATION)
130
+ if not raw_content:
131
+ return {".": UNKNOWN}
132
+
133
+ manifest: VersionManifest = json.loads(raw_content)
134
+ return manifest
135
+
136
+
137
+ @lru_cache()
138
+ def get_file_contents(file_path: str) -> str | None:
139
+ """Attempts to read a file from the filesystem and return the contents"""
140
+ try:
141
+ with open(file_path) as f:
142
+ return f.read().replace("\n", "")
143
+ except FileNotFoundError:
144
+ return None
145
+
146
+
147
+ @lru_cache()
148
+ def is_database_replica_setup() -> bool:
149
+ """Checks if any database replica is set up"""
150
+ return any(
151
+ name for name in connections if name.startswith(get_args(ReplicaNamePrefix))
152
+ )
153
+
154
+
155
+ def using_database_replica(
156
+ manager: ManagerType,
157
+ replica_prefix: ReplicaNamePrefix = "replica_",
158
+ ) -> ManagerType:
159
+ """Attempts to bind a manager to a healthy database replica"""
160
+ local_replicas = [name for name in connections if name.startswith(replica_prefix)]
161
+
162
+ if not local_replicas:
163
+ logger.info("No replicas set up.")
164
+ return manager
165
+
166
+ chosen_replica = None
167
+
168
+ if settings.REPLICA_READ_STRATEGY == ReplicaReadStrategy.SEQUENTIAL:
169
+ sequence = _replica_sequential_names_by_prefix.setdefault(
170
+ replica_prefix, cycle(local_replicas)
171
+ )
172
+ for _ in range(len(local_replicas)):
173
+ attempted_replica = next(sequence)
174
+ try:
175
+ connections[attempted_replica].ensure_connection()
176
+ chosen_replica = attempted_replica
177
+ break
178
+ except OperationalError:
179
+ logger.exception(f"Replica '{attempted_replica}' is not available.")
180
+ continue
181
+
182
+ if settings.REPLICA_READ_STRATEGY == ReplicaReadStrategy.DISTRIBUTED:
183
+ for _ in range(len(local_replicas)):
184
+ attempted_replica = random.choice(local_replicas)
185
+ try:
186
+ connections[attempted_replica].ensure_connection()
187
+ chosen_replica = attempted_replica
188
+ break
189
+ except OperationalError:
190
+ logger.exception(f"Replica '{attempted_replica}' is not available.")
191
+ local_replicas.remove(attempted_replica)
192
+ continue
193
+
194
+ if not chosen_replica:
195
+ if replica_prefix == "replica_":
196
+ logger.warning("Falling back to cross-region replicas, if any.")
197
+ return using_database_replica(manager, "cross_region_replica_")
198
+
199
+ logger.warning("No replicas available.")
200
+ return manager
201
+
202
+ return manager.db_manager(chosen_replica)
203
+
204
+
205
+ if sys.version_info >= (3, 12):
206
+ # Already has the desired behavior; re-export for uniform imports.
207
+ TemporaryDirectory = tempfile.TemporaryDirectory
208
+ else:
209
+ import contextlib
210
+ from typing import ContextManager, Generator
211
+
212
+ def TemporaryDirectory(
213
+ suffix: str | None = None,
214
+ prefix: str | None = None,
215
+ dir: str | None = None,
216
+ *,
217
+ delete: bool = True,
218
+ ) -> ContextManager[str]:
219
+ """
220
+ Create a temporary directory with optional cleanup control.
221
+
222
+ This wrapper exists because Python 3.12 changed TemporaryDirectory's behavior
223
+ by adding a 'delete' parameter, which doesn't exist in Python 3.11. This
224
+ function provides a consistent API across both versions.
225
+
226
+ When delete=True, uses the stdlib's TemporaryDirectory (auto-cleanup).
227
+ When delete=False, creates a directory with mkdtemp that persists after
228
+ the context manager exits, matching Python 3.12's delete=False behavior.
229
+
230
+ See https://docs.python.org/3.12/library/tempfile.html#tempfile.TemporaryDirectory for usage details.
231
+ """
232
+ if delete:
233
+ return tempfile.TemporaryDirectory(suffix, prefix, dir)
234
+
235
+ @contextlib.contextmanager
236
+ def _tmpdir() -> Generator[str, None, None]:
237
+ yield tempfile.mkdtemp(suffix, prefix, dir)
238
+
239
+ return _tmpdir()
common/core/views.py ADDED
@@ -0,0 +1,27 @@
1
+ import logging
2
+
3
+ import prometheus_client
4
+ from django.http import HttpResponse, JsonResponse
5
+ from rest_framework.request import Request
6
+
7
+ from common.core import utils
8
+ from common.prometheus.utils import get_registry
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def liveness(request: Request) -> JsonResponse:
14
+ return JsonResponse({"status": "ok"})
15
+
16
+
17
+ def version_info(request: Request) -> JsonResponse:
18
+ return JsonResponse(utils.get_version_info())
19
+
20
+
21
+ def metrics(request: Request) -> HttpResponse:
22
+ registry = get_registry()
23
+ metrics_page = prometheus_client.generate_latest(registry)
24
+ return HttpResponse(
25
+ metrics_page,
26
+ content_type=prometheus_client.CONTENT_TYPE_LATEST,
27
+ )
@@ -0,0 +1,15 @@
1
+ # Maintain a list of permissions here
2
+ VIEW_ENVIRONMENT = "VIEW_ENVIRONMENT"
3
+ UPDATE_FEATURE_STATE = "UPDATE_FEATURE_STATE"
4
+ MANAGE_IDENTITIES = "MANAGE_IDENTITIES"
5
+ VIEW_IDENTITIES = "VIEW_IDENTITIES"
6
+ CREATE_CHANGE_REQUEST = "CREATE_CHANGE_REQUEST"
7
+ APPROVE_CHANGE_REQUEST = "APPROVE_CHANGE_REQUEST"
8
+ MANAGE_SEGMENT_OVERRIDES = "MANAGE_SEGMENT_OVERRIDES"
9
+
10
+ TAG_SUPPORTED_PERMISSIONS = [
11
+ UPDATE_FEATURE_STATE,
12
+ CREATE_CHANGE_REQUEST,
13
+ APPROVE_CHANGE_REQUEST,
14
+ MANAGE_SEGMENT_OVERRIDES,
15
+ ]
File without changes
File without changes
@@ -0,0 +1,19 @@
1
+ import typing
2
+
3
+ from django.apps import apps
4
+ from rest_framework import serializers
5
+
6
+ if typing.TYPE_CHECKING:
7
+ from common.types import MultivariateFeatureStateValue # noqa: F401
8
+
9
+
10
+ class MultivariateFeatureStateValueSerializer(
11
+ serializers.ModelSerializer["MultivariateFeatureStateValue"]
12
+ ):
13
+ class Meta:
14
+ model = apps.get_model("multivariate", "MultivariateFeatureStateValue")
15
+ fields = (
16
+ "id",
17
+ "multivariate_feature_option",
18
+ "percentage_allocation",
19
+ )
@@ -0,0 +1,68 @@
1
+ import typing
2
+
3
+ from django.apps import apps
4
+ from drf_writable_nested.serializers import WritableNestedModelSerializer
5
+ from rest_framework import serializers
6
+
7
+ from common.features.multivariate.serializers import (
8
+ MultivariateFeatureStateValueSerializer,
9
+ )
10
+
11
+ if typing.TYPE_CHECKING:
12
+ from common.types import FeatureSegment, FeatureStateValue # noqa: F401
13
+
14
+
15
+ class FeatureStateValueSerializer(serializers.ModelSerializer["FeatureStateValue"]):
16
+ class Meta:
17
+ model = apps.get_model("features", "FeatureStateValue")
18
+ fields = ("type", "string_value", "integer_value", "boolean_value")
19
+
20
+
21
+ class CreateSegmentOverrideFeatureSegmentSerializer(
22
+ serializers.ModelSerializer["FeatureSegment"]
23
+ ):
24
+ class Meta:
25
+ model = apps.get_model("features", "FeatureSegment")
26
+ fields = ("id", "segment", "priority", "uuid")
27
+
28
+
29
+ class CreateSegmentOverrideFeatureStateSerializer(WritableNestedModelSerializer):
30
+ feature_state_value = FeatureStateValueSerializer()
31
+ feature_segment = CreateSegmentOverrideFeatureSegmentSerializer(
32
+ required=False, allow_null=True
33
+ )
34
+ multivariate_feature_state_values = MultivariateFeatureStateValueSerializer(
35
+ many=True, required=False
36
+ )
37
+
38
+ class Meta:
39
+ model = apps.get_model("features", "FeatureState")
40
+ fields = (
41
+ "id",
42
+ "feature",
43
+ "enabled",
44
+ "feature_state_value",
45
+ "feature_segment",
46
+ "deleted_at",
47
+ "uuid",
48
+ "created_at",
49
+ "updated_at",
50
+ "live_from",
51
+ "environment",
52
+ "identity",
53
+ "change_request",
54
+ "multivariate_feature_state_values",
55
+ )
56
+
57
+ read_only_fields = (
58
+ "id",
59
+ "deleted_at",
60
+ "uuid",
61
+ "created_at",
62
+ "updated_at",
63
+ "live_from",
64
+ "environment",
65
+ "identity",
66
+ "change_request",
67
+ "feature",
68
+ )
File without changes
@@ -0,0 +1,13 @@
1
+ from common.features.serializers import (
2
+ CreateSegmentOverrideFeatureStateSerializer,
3
+ )
4
+
5
+
6
+ class EnvironmentFeatureVersionFeatureStateSerializer(
7
+ CreateSegmentOverrideFeatureStateSerializer
8
+ ):
9
+ class Meta(CreateSegmentOverrideFeatureStateSerializer.Meta):
10
+ read_only_fields = (
11
+ CreateSegmentOverrideFeatureStateSerializer.Meta.read_only_fields
12
+ + ("feature",) # type: ignore[assignment]
13
+ )
File without changes
@@ -0,0 +1,18 @@
1
+ """
2
+ This module is used as a default configuration file for Gunicorn.
3
+
4
+ It is used to correctly support Prometheus metrics in a multi-process environment.
5
+ """
6
+
7
+ import typing
8
+
9
+ from prometheus_client.multiprocess import mark_process_dead
10
+
11
+ if typing.TYPE_CHECKING: # pragma: no cover
12
+ from gunicorn.arbiter import Arbiter # type: ignore[import-untyped]
13
+ from gunicorn.workers.base import Worker # type: ignore[import-untyped]
14
+
15
+
16
+ def worker_exit(server: "Arbiter", worker: "Worker") -> None:
17
+ """Detach the process Prometheus metrics collector when a worker exits."""
18
+ mark_process_dead(worker.pid) # type: ignore[no-untyped-call]
@@ -0,0 +1,23 @@
1
+ import re
2
+
3
+ WSGI_EXTRA_PREFIX = "flagsmith."
4
+ WSGI_EXTRA_SUFFIX_TO_CATEGORY = {
5
+ "i": "request_headers",
6
+ "o": "response_headers",
7
+ "e": "environ_variables",
8
+ }
9
+ HTTP_SERVER_RESPONSE_SIZE_DEFAULT_BUCKETS = (
10
+ # 1 kB, 10 kB, 100 kB, 500 kB, 1 MB, 5 MB, 10 MB
11
+ 1 * 1024,
12
+ 10 * 1024,
13
+ 100 * 1024,
14
+ 500 * 1024,
15
+ 1 * 1024 * 1024,
16
+ 5 * 1024 * 1024,
17
+ 10 * 1024 * 1024,
18
+ float("inf"),
19
+ )
20
+
21
+ wsgi_extra_key_regex = re.compile(
22
+ r"^{(?P<key>[^}]+)}(?P<suffix>[%s])$" % "".join(WSGI_EXTRA_SUFFIX_TO_CATEGORY)
23
+ )
@@ -0,0 +1,120 @@
1
+ import logging
2
+ import sys
3
+ from datetime import datetime, timedelta
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from django.conf import settings
7
+ from gunicorn.config import Config # type: ignore[import-untyped]
8
+ from gunicorn.http.message import Request # type: ignore[import-untyped]
9
+ from gunicorn.http.wsgi import Response # type: ignore[import-untyped]
10
+ from gunicorn.instrument.statsd import ( # type: ignore[import-untyped]
11
+ Statsd as StatsdGunicornLogger,
12
+ )
13
+
14
+ from common.core.logging import JsonFormatter
15
+ from common.gunicorn import metrics
16
+ from common.gunicorn.constants import (
17
+ WSGI_EXTRA_PREFIX,
18
+ WSGI_EXTRA_SUFFIX_TO_CATEGORY,
19
+ wsgi_extra_key_regex,
20
+ )
21
+ from common.gunicorn.utils import get_extra
22
+
23
+
24
+ class GunicornAccessLogJsonFormatter(JsonFormatter):
25
+ def _get_extra(self, record_args: dict[str, Any]) -> dict[str, Any]:
26
+ ret: dict[str, dict[str, Any]] = {}
27
+
28
+ extra_items_to_log: list[str] | None
29
+ if extra_items_to_log := getattr(settings, "ACCESS_LOG_EXTRA_ITEMS", None):
30
+ # We expect the extra items to be in the form of
31
+ # Gunicorn's access log format string for
32
+ # request headers, response headers and environ variables
33
+ # without the % prefix, e.g. "{origin}i" or "{flagsmith.environment_id}e"
34
+ # https://docs.gunicorn.org/en/stable/settings.html#access-log-format
35
+ for extra_key in extra_items_to_log:
36
+ extra_key_lower = extra_key.lower()
37
+ if (
38
+ (extra_value := record_args.get(extra_key_lower))
39
+ and (re_match := wsgi_extra_key_regex.match(extra_key_lower))
40
+ and (
41
+ extra_category := WSGI_EXTRA_SUFFIX_TO_CATEGORY.get(
42
+ re_match.group("suffix")
43
+ )
44
+ )
45
+ ):
46
+ ret.setdefault(extra_category, {})[re_match.group("key")] = (
47
+ extra_value
48
+ )
49
+
50
+ return ret
51
+
52
+ def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]:
53
+ args = record.args
54
+
55
+ if TYPE_CHECKING:
56
+ assert isinstance(args, dict)
57
+
58
+ url = args["U"]
59
+ if q := args["q"]:
60
+ url += f"?{q}"
61
+
62
+ return {
63
+ **super().get_json_record(record),
64
+ "time": datetime.strptime(args["t"], "[%d/%b/%Y:%H:%M:%S %z]").isoformat(),
65
+ "path": url,
66
+ "remote_ip": args["h"],
67
+ "route": args.get(f"{{{WSGI_EXTRA_PREFIX}route}}e") or "",
68
+ "method": args["m"],
69
+ "status": str(args["s"]),
70
+ "user_agent": args["a"],
71
+ "duration_in_ms": args["M"],
72
+ "response_size_in_bytes": args["B"] or 0,
73
+ **self._get_extra(args),
74
+ }
75
+
76
+
77
+ class PrometheusGunicornLogger(StatsdGunicornLogger): # type: ignore[misc]
78
+ def access(
79
+ self,
80
+ resp: Response,
81
+ req: Request,
82
+ environ: dict[str, Any],
83
+ request_time: timedelta,
84
+ ) -> None:
85
+ super().access(resp, req, environ, request_time)
86
+ duration_seconds = (
87
+ request_time.seconds + float(request_time.microseconds) / 10**6
88
+ )
89
+ labels = {
90
+ # To avoid cardinality explosion, we use a resolved Django route
91
+ # instead of raw path.
92
+ # The Django route is set by `RouteLoggerMiddleware`.
93
+ "route": get_extra(environ=environ, key="route") or "",
94
+ "method": environ.get("REQUEST_METHOD") or "",
95
+ "response_status": resp.status_code,
96
+ }
97
+ metrics.flagsmith_http_server_request_duration_seconds.labels(**labels).observe(
98
+ duration_seconds
99
+ )
100
+ metrics.flagsmith_http_server_requests_total.labels(**labels).inc()
101
+ metrics.flagsmith_http_server_response_size_bytes.labels(**labels).observe(
102
+ getattr(resp, "sent", 0),
103
+ )
104
+
105
+
106
+ class GunicornJsonCapableLogger(PrometheusGunicornLogger):
107
+ def setup(self, cfg: Config) -> None:
108
+ super().setup(cfg)
109
+ if getattr(settings, "LOG_FORMAT", None) == "json":
110
+ self._set_handler(
111
+ self.error_log,
112
+ cfg.errorlog,
113
+ JsonFormatter(),
114
+ )
115
+ self._set_handler(
116
+ self.access_log,
117
+ cfg.accesslog,
118
+ GunicornAccessLogJsonFormatter(),
119
+ stream=sys.stdout,
120
+ )
@@ -0,0 +1,26 @@
1
+ import prometheus_client
2
+ from django.conf import settings
3
+
4
+ from common.gunicorn.constants import HTTP_SERVER_RESPONSE_SIZE_DEFAULT_BUCKETS
5
+ from common.prometheus import Histogram
6
+
7
+ flagsmith_http_server_requests_total = prometheus_client.Counter(
8
+ "flagsmith_http_server_requests_total",
9
+ "Total number of HTTP requests.",
10
+ ["route", "method", "response_status"],
11
+ )
12
+ flagsmith_http_server_request_duration_seconds = Histogram(
13
+ "flagsmith_http_server_request_duration_seconds",
14
+ "HTTP request duration in seconds.",
15
+ ["route", "method", "response_status"],
16
+ )
17
+ flagsmith_http_server_response_size_bytes = Histogram(
18
+ "flagsmith_http_server_response_size_bytes",
19
+ "HTTP response size in bytes.",
20
+ ["route", "method", "response_status"],
21
+ buckets=getattr(
22
+ settings,
23
+ "PROMETHEUS_HTTP_SERVER_RESPONSE_SIZE_HISTOGRAM_BUCKETS",
24
+ HTTP_SERVER_RESPONSE_SIZE_DEFAULT_BUCKETS,
25
+ ),
26
+ )
@@ -0,0 +1,30 @@
1
+ from typing import Callable
2
+
3
+ from django.http import HttpRequest, HttpResponse
4
+
5
+ from common.gunicorn.utils import get_route_template, log_extra
6
+
7
+
8
+ class RouteLoggerMiddleware:
9
+ """
10
+ Make the resolved Django route available to the WSGI server
11
+ (e.g. Gunicorn) for logging purposes.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ get_response: Callable[[HttpRequest], HttpResponse],
17
+ ) -> None:
18
+ self.get_response = get_response
19
+
20
+ def __call__(self, request: HttpRequest) -> HttpResponse:
21
+ response = self.get_response(request)
22
+
23
+ if resolver_match := request.resolver_match:
24
+ log_extra(
25
+ request=request,
26
+ key="route",
27
+ value=get_route_template(resolver_match.route),
28
+ )
29
+
30
+ return response