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.
- common/__init__.py +0 -0
- common/core/__init__.py +6 -0
- common/core/app.py +6 -0
- common/core/cli/__init__.py +0 -0
- common/core/cli/healthcheck.py +120 -0
- common/core/logging.py +24 -0
- common/core/main.py +105 -0
- common/core/management/__init__.py +0 -0
- common/core/management/commands/__init__.py +0 -0
- common/core/management/commands/docgen.py +63 -0
- common/core/management/commands/start.py +61 -0
- common/core/management/commands/waitfordb.py +87 -0
- common/core/metrics.py +25 -0
- common/core/middleware.py +22 -0
- common/core/templates/docgen-metrics.md +22 -0
- common/core/urls.py +17 -0
- common/core/utils.py +239 -0
- common/core/views.py +27 -0
- common/environments/permissions.py +15 -0
- common/features/__init__.py +0 -0
- common/features/multivariate/__init__.py +0 -0
- common/features/multivariate/serializers.py +19 -0
- common/features/serializers.py +68 -0
- common/features/versioning/__init__.py +0 -0
- common/features/versioning/serializers.py +13 -0
- common/gunicorn/__init__.py +0 -0
- common/gunicorn/conf.py +18 -0
- common/gunicorn/constants.py +23 -0
- common/gunicorn/logging.py +120 -0
- common/gunicorn/metrics.py +26 -0
- common/gunicorn/middleware.py +30 -0
- common/gunicorn/utils.py +104 -0
- common/migrations/__init__.py +0 -0
- common/migrations/helpers/__init__.py +9 -0
- common/migrations/helpers/postgres_helpers.py +41 -0
- common/organisations/permissions.py +10 -0
- common/projects/permissions.py +40 -0
- common/prometheus/__init__.py +3 -0
- common/prometheus/utils.py +38 -0
- common/py.typed +0 -0
- common/test_tools/__init__.py +11 -0
- common/test_tools/plugin.py +139 -0
- common/test_tools/types.py +56 -0
- common/test_tools/utils.py +11 -0
- common/types.py +45 -0
- flagsmith_common-2.2.4.dist-info/METADATA +196 -0
- flagsmith_common-2.2.4.dist-info/RECORD +92 -0
- flagsmith_common-2.2.4.dist-info/WHEEL +4 -0
- flagsmith_common-2.2.4.dist-info/entry_points.txt +6 -0
- flagsmith_common-2.2.4.dist-info/licenses/LICENSE +28 -0
- task_processor/__init__.py +0 -0
- task_processor/admin.py +38 -0
- task_processor/apps.py +47 -0
- task_processor/decorators.py +209 -0
- task_processor/exceptions.py +28 -0
- task_processor/health.py +44 -0
- task_processor/managers.py +18 -0
- task_processor/metrics.py +22 -0
- task_processor/migrations/0001_initial.py +44 -0
- task_processor/migrations/0002_healthcheckmodel.py +21 -0
- task_processor/migrations/0003_add_completed_to_task.py +22 -0
- task_processor/migrations/0004_recreate_task_indexes.py +43 -0
- task_processor/migrations/0005_update_conditional_index_conditions.py +45 -0
- task_processor/migrations/0006_auto_20230221_0802.py +45 -0
- task_processor/migrations/0007_add_is_locked.py +23 -0
- task_processor/migrations/0008_add_get_task_to_process_function.py +31 -0
- task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +18 -0
- task_processor/migrations/0010_task_priority.py +27 -0
- task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +27 -0
- task_processor/migrations/0012_add_locked_at_and_timeout.py +40 -0
- task_processor/migrations/0013_add_last_picked_at.py +34 -0
- task_processor/migrations/__init__.py +0 -0
- task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0008_get_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0011_get_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +33 -0
- task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +33 -0
- task_processor/migrations/sql/__init__.py +0 -0
- task_processor/models.py +237 -0
- task_processor/monitoring.py +12 -0
- task_processor/processor.py +202 -0
- task_processor/py.typed +0 -0
- task_processor/routers.py +55 -0
- task_processor/serializers.py +7 -0
- task_processor/task_registry.py +90 -0
- task_processor/task_run_method.py +7 -0
- task_processor/tasks.py +71 -0
- task_processor/threads.py +128 -0
- task_processor/types.py +18 -0
- task_processor/urls.py +5 -0
- task_processor/utils.py +71 -0
- 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
|
common/gunicorn/conf.py
ADDED
|
@@ -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
|