flagsmith-common 1.5.0__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 +0 -0
- common/core/app.py +6 -0
- common/core/logging.py +24 -0
- common/core/main.py +40 -0
- common/core/management/__init__.py +0 -0
- common/core/management/commands/__init__.py +0 -0
- common/core/management/commands/start.py +41 -0
- common/core/metrics.py +23 -0
- common/core/urls.py +17 -0
- common/core/utils.py +90 -0
- common/core/views.py +23 -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 +1 -0
- common/gunicorn/logging.py +94 -0
- common/gunicorn/metrics.py +14 -0
- common/gunicorn/middleware.py +28 -0
- common/gunicorn/utils.py +61 -0
- common/metadata/serializers.py +100 -0
- common/organisations/permissions.py +10 -0
- common/projects/permissions.py +40 -0
- common/prometheus/__init__.py +3 -0
- common/prometheus/utils.py +18 -0
- common/py.typed +0 -0
- common/segments/serializers.py +338 -0
- common/test_tools/__init__.py +3 -0
- common/test_tools/plugin.py +34 -0
- common/test_tools/types.py +11 -0
- common/types.py +45 -0
- flagsmith_common-1.5.0.dist-info/LICENSE +28 -0
- flagsmith_common-1.5.0.dist-info/METADATA +128 -0
- flagsmith_common-1.5.0.dist-info/RECORD +42 -0
- flagsmith_common-1.5.0.dist-info/WHEEL +4 -0
- flagsmith_common-1.5.0.dist-info/entry_points.txt +6 -0
common/__init__.py
ADDED
|
File without changes
|
common/core/__init__.py
ADDED
|
File without changes
|
common/core/app.py
ADDED
common/core/logging.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JsonFormatter(logging.Formatter):
|
|
7
|
+
"""Custom formatter for json logs."""
|
|
8
|
+
|
|
9
|
+
def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]:
|
|
10
|
+
formatted_message = record.getMessage()
|
|
11
|
+
json_record = {
|
|
12
|
+
"levelname": record.levelname,
|
|
13
|
+
"message": formatted_message,
|
|
14
|
+
"timestamp": self.formatTime(record, self.datefmt),
|
|
15
|
+
"logger_name": record.name,
|
|
16
|
+
"pid": record.process,
|
|
17
|
+
"thread_name": record.threadName,
|
|
18
|
+
}
|
|
19
|
+
if record.exc_info:
|
|
20
|
+
json_record["exc_info"] = self.formatException(record.exc_info)
|
|
21
|
+
return json_record
|
|
22
|
+
|
|
23
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
24
|
+
return json.dumps(self.get_json_record(record))
|
common/core/main.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
from django.core.management import execute_from_command_line
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
"""
|
|
13
|
+
The main entry point to the Flagsmith application.
|
|
14
|
+
|
|
15
|
+
An equivalent to Django's `manage.py` script, this module is used to run management commands.
|
|
16
|
+
|
|
17
|
+
It's installed as the `flagsmith` command.
|
|
18
|
+
|
|
19
|
+
Everything that needs to be run before Django is started should be done here.
|
|
20
|
+
|
|
21
|
+
The end goal is to eventually replace Core API's `run-docker.sh` with this.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
`flagsmith <command> [options]`
|
|
25
|
+
"""
|
|
26
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")
|
|
27
|
+
|
|
28
|
+
# Set up Prometheus' multiprocess mode
|
|
29
|
+
if "PROMETHEUS_MULTIPROC_DIR" not in os.environ:
|
|
30
|
+
prometheus_multiproc_dir = tempfile.TemporaryDirectory(
|
|
31
|
+
prefix="prometheus_multiproc",
|
|
32
|
+
)
|
|
33
|
+
logger.info(
|
|
34
|
+
"Created %s for Prometheus multi-process mode",
|
|
35
|
+
prometheus_multiproc_dir.name,
|
|
36
|
+
)
|
|
37
|
+
os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_multiproc_dir.name
|
|
38
|
+
|
|
39
|
+
# Run Django
|
|
40
|
+
execute_from_command_line(sys.argv)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
|
|
3
|
+
from django.core.management import BaseCommand, CommandParser
|
|
4
|
+
from django.utils.module_loading import autodiscover_modules
|
|
5
|
+
|
|
6
|
+
from common.gunicorn.utils import add_arguments, run_server
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Command(BaseCommand):
|
|
10
|
+
help = "Start the Flagsmith application."
|
|
11
|
+
|
|
12
|
+
def create_parser(self, *args: Any, **kwargs: Any) -> CommandParser:
|
|
13
|
+
return super().create_parser(*args, conflict_handler="resolve", **kwargs)
|
|
14
|
+
|
|
15
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
16
|
+
add_arguments(parser)
|
|
17
|
+
|
|
18
|
+
subparsers = parser.add_subparsers(
|
|
19
|
+
title="sub-commands",
|
|
20
|
+
required=True,
|
|
21
|
+
)
|
|
22
|
+
api_parser = subparsers.add_parser(
|
|
23
|
+
"api",
|
|
24
|
+
help="Start the Core API.",
|
|
25
|
+
)
|
|
26
|
+
api_parser.set_defaults(handle_method=self.handle_api)
|
|
27
|
+
|
|
28
|
+
def initialise(self) -> None:
|
|
29
|
+
autodiscover_modules("metrics")
|
|
30
|
+
|
|
31
|
+
def handle(
|
|
32
|
+
self,
|
|
33
|
+
*args: Any,
|
|
34
|
+
handle_method: Callable[..., None],
|
|
35
|
+
**options: Any,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.initialise()
|
|
38
|
+
handle_method(*args, **options)
|
|
39
|
+
|
|
40
|
+
def handle_api(self, *args: Any, **options: Any) -> None:
|
|
41
|
+
run_server(options)
|
common/core/metrics.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import prometheus_client
|
|
2
|
+
|
|
3
|
+
from common.core.utils import get_version_info
|
|
4
|
+
|
|
5
|
+
flagsmith_build_info = prometheus_client.Gauge(
|
|
6
|
+
"flagsmith_build_info",
|
|
7
|
+
"Flagsmith version and build information",
|
|
8
|
+
["ci_commit_sha", "version"],
|
|
9
|
+
multiprocess_mode="livemax",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def advertise() -> None:
|
|
14
|
+
# Advertise Flagsmith build info.
|
|
15
|
+
version_info = get_version_info()
|
|
16
|
+
|
|
17
|
+
flagsmith_build_info.labels(
|
|
18
|
+
ci_commit_sha=version_info["ci_commit_sha"],
|
|
19
|
+
version=version_info.get("package_versions", {}).get(".") or "unknown",
|
|
20
|
+
).set(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
advertise()
|
common/core/urls.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.urls import include, re_path
|
|
3
|
+
|
|
4
|
+
from common.core import views
|
|
5
|
+
|
|
6
|
+
urlpatterns = [
|
|
7
|
+
re_path(r"^version/?", views.version_info),
|
|
8
|
+
re_path(r"^health/liveness/?", views.version_info),
|
|
9
|
+
re_path(r"^health/readiness/?", include("health_check.urls", namespace="health")),
|
|
10
|
+
re_path(r"^health", include("health_check.urls", namespace="health-deprecated")),
|
|
11
|
+
# Aptible health checks must be on /healthcheck and cannot redirect
|
|
12
|
+
# see https://www.aptible.com/docs/core-concepts/apps/connecting-to-apps/app-endpoints/https-endpoints/health-checks
|
|
13
|
+
re_path(r"^healthcheck", include("health_check.urls", namespace="health-aptible")),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
if settings.PROMETHEUS_ENABLED:
|
|
17
|
+
urlpatterns += [re_path(r"^metrics/?", views.metrics)]
|
common/core/utils.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pathlib
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.contrib.auth import get_user_model
|
|
8
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
9
|
+
from django.db.models import Manager
|
|
10
|
+
|
|
11
|
+
UNKNOWN = "unknown"
|
|
12
|
+
VERSIONS_INFO_FILE_LOCATION = ".versions.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SelfHostedData(TypedDict):
|
|
16
|
+
has_users: bool
|
|
17
|
+
has_logins: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VersionInfo(TypedDict):
|
|
21
|
+
ci_commit_sha: str
|
|
22
|
+
image_tag: str
|
|
23
|
+
has_email_provider: bool
|
|
24
|
+
is_enterprise: bool
|
|
25
|
+
is_saas: bool
|
|
26
|
+
self_hosted_data: SelfHostedData | None
|
|
27
|
+
package_versions: NotRequired[dict[str, str]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache()
|
|
31
|
+
def is_enterprise() -> bool:
|
|
32
|
+
return pathlib.Path("./ENTERPRISE_VERSION").exists()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache()
|
|
36
|
+
def is_saas() -> bool:
|
|
37
|
+
return pathlib.Path("./SAAS_DEPLOYMENT").exists()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@lru_cache()
|
|
41
|
+
def has_email_provider() -> bool:
|
|
42
|
+
match settings.EMAIL_BACKEND:
|
|
43
|
+
case "django.core.mail.backends.smtp.EmailBackend":
|
|
44
|
+
return settings.EMAIL_HOST_USER is not None
|
|
45
|
+
case "sgbackend.SendGridBackend":
|
|
46
|
+
return settings.SENDGRID_API_KEY is not None
|
|
47
|
+
case "django_ses.SESBackend":
|
|
48
|
+
return settings.AWS_SES_REGION_ENDPOINT is not None
|
|
49
|
+
case _:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_version_info() -> VersionInfo:
|
|
54
|
+
"""Reads the version info baked into src folder of the docker container"""
|
|
55
|
+
_is_saas = is_saas()
|
|
56
|
+
version_json: VersionInfo = {
|
|
57
|
+
"ci_commit_sha": get_file_contents("./CI_COMMIT_SHA") or UNKNOWN,
|
|
58
|
+
"image_tag": UNKNOWN,
|
|
59
|
+
"has_email_provider": has_email_provider(),
|
|
60
|
+
"is_enterprise": is_enterprise(),
|
|
61
|
+
"is_saas": _is_saas,
|
|
62
|
+
"self_hosted_data": None,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
manifest_versions_content = get_file_contents(VERSIONS_INFO_FILE_LOCATION)
|
|
66
|
+
|
|
67
|
+
if manifest_versions_content:
|
|
68
|
+
manifest_versions = json.loads(manifest_versions_content)
|
|
69
|
+
version_json["package_versions"] = manifest_versions
|
|
70
|
+
version_json["image_tag"] = manifest_versions["."]
|
|
71
|
+
|
|
72
|
+
if not _is_saas:
|
|
73
|
+
user_objects: Manager[AbstractBaseUser] = getattr(get_user_model(), "objects")
|
|
74
|
+
|
|
75
|
+
version_json["self_hosted_data"] = {
|
|
76
|
+
"has_users": user_objects.exists(),
|
|
77
|
+
"has_logins": user_objects.filter(last_login__isnull=False).exists(),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return version_json
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@lru_cache()
|
|
84
|
+
def get_file_contents(file_path: str) -> str | None:
|
|
85
|
+
"""Attempts to read a file from the filesystem and return the contents"""
|
|
86
|
+
try:
|
|
87
|
+
with open(file_path) as f:
|
|
88
|
+
return f.read().replace("\n", "")
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
return None
|
common/core/views.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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 version_info(request: Request) -> JsonResponse:
|
|
14
|
+
return JsonResponse(utils.get_version_info())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def metrics(request: Request) -> HttpResponse:
|
|
18
|
+
registry = get_registry()
|
|
19
|
+
metrics_page = prometheus_client.generate_latest(registry)
|
|
20
|
+
return HttpResponse(
|
|
21
|
+
metrics_page,
|
|
22
|
+
content_type=prometheus_client.CONTENT_TYPE_LATEST,
|
|
23
|
+
)
|
|
@@ -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 @@
|
|
|
1
|
+
WSGI_DJANGO_ROUTE_ENVIRON_KEY = "wsgi.django_route"
|
|
@@ -0,0 +1,94 @@
|
|
|
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 WSGI_DJANGO_ROUTE_ENVIRON_KEY
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GunicornAccessLogJsonFormatter(JsonFormatter):
|
|
20
|
+
def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]:
|
|
21
|
+
args = record.args
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
assert isinstance(args, dict)
|
|
25
|
+
|
|
26
|
+
url = args["U"]
|
|
27
|
+
if q := args["q"]:
|
|
28
|
+
url += f"?{q}"
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
**super().get_json_record(record),
|
|
32
|
+
"time": datetime.strptime(args["t"], "[%d/%b/%Y:%H:%M:%S %z]").isoformat(),
|
|
33
|
+
"path": url,
|
|
34
|
+
"remote_ip": args["h"],
|
|
35
|
+
"route": args["R"],
|
|
36
|
+
"method": args["m"],
|
|
37
|
+
"status": str(args["s"]),
|
|
38
|
+
"user_agent": args["a"],
|
|
39
|
+
"duration_in_ms": args["M"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PrometheusGunicornLogger(StatsdGunicornLogger): # type: ignore[misc]
|
|
44
|
+
def access(
|
|
45
|
+
self,
|
|
46
|
+
resp: Response,
|
|
47
|
+
req: Request,
|
|
48
|
+
environ: dict[str, Any],
|
|
49
|
+
request_time: timedelta,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().access(resp, req, environ, request_time)
|
|
52
|
+
duration_seconds = (
|
|
53
|
+
request_time.seconds + float(request_time.microseconds) / 10**6
|
|
54
|
+
)
|
|
55
|
+
labels = {
|
|
56
|
+
# To avoid cardinality explosion, we use a resolved Django route
|
|
57
|
+
# instead of raw path.
|
|
58
|
+
# The Django route is set by `RouteLoggerMiddleware`.
|
|
59
|
+
"route": environ.get(WSGI_DJANGO_ROUTE_ENVIRON_KEY) or "",
|
|
60
|
+
"method": environ.get("REQUEST_METHOD") or "",
|
|
61
|
+
"response_status": resp.status_code,
|
|
62
|
+
}
|
|
63
|
+
metrics.http_server_request_duration_seconds.labels(**labels).observe(
|
|
64
|
+
duration_seconds
|
|
65
|
+
)
|
|
66
|
+
metrics.http_server_requests_total.labels(**labels).inc()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GunicornJsonCapableLogger(PrometheusGunicornLogger):
|
|
70
|
+
def atoms(
|
|
71
|
+
self,
|
|
72
|
+
resp: Response,
|
|
73
|
+
req: Request,
|
|
74
|
+
environ: dict[str, Any],
|
|
75
|
+
request_time: timedelta,
|
|
76
|
+
) -> dict[str, str]:
|
|
77
|
+
atoms: dict[str, str] = super().atoms(resp, req, environ, request_time)
|
|
78
|
+
atoms["R"] = environ.get(WSGI_DJANGO_ROUTE_ENVIRON_KEY) or "-"
|
|
79
|
+
return atoms
|
|
80
|
+
|
|
81
|
+
def setup(self, cfg: Config) -> None:
|
|
82
|
+
super().setup(cfg)
|
|
83
|
+
if getattr(settings, "LOG_FORMAT", None) == "json":
|
|
84
|
+
self._set_handler(
|
|
85
|
+
self.error_log,
|
|
86
|
+
cfg.errorlog,
|
|
87
|
+
JsonFormatter(),
|
|
88
|
+
)
|
|
89
|
+
self._set_handler(
|
|
90
|
+
self.access_log,
|
|
91
|
+
cfg.accesslog,
|
|
92
|
+
GunicornAccessLogJsonFormatter(),
|
|
93
|
+
stream=sys.stdout,
|
|
94
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import prometheus_client
|
|
2
|
+
|
|
3
|
+
from common.prometheus import Histogram
|
|
4
|
+
|
|
5
|
+
http_server_requests_total = prometheus_client.Counter(
|
|
6
|
+
"http_server_requests_total",
|
|
7
|
+
"Total number of HTTP requests",
|
|
8
|
+
["route", "method", "response_status"],
|
|
9
|
+
)
|
|
10
|
+
http_server_request_duration_seconds = Histogram(
|
|
11
|
+
"http_server_request_duration_seconds",
|
|
12
|
+
"HTTP request duration in seconds",
|
|
13
|
+
["route", "method", "response_status"],
|
|
14
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
from django.http import HttpRequest, HttpResponse
|
|
4
|
+
|
|
5
|
+
from common.gunicorn.constants import WSGI_DJANGO_ROUTE_ENVIRON_KEY
|
|
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
|
+
# https://peps.python.org/pep-3333/#specification-details
|
|
25
|
+
# "...the application is allowed to modify the dictionary in any way it desires"
|
|
26
|
+
request.META[WSGI_DJANGO_ROUTE_ENVIRON_KEY] = resolver_match.route
|
|
27
|
+
|
|
28
|
+
return response
|
common/gunicorn/utils.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.core.handlers.wsgi import WSGIHandler
|
|
6
|
+
from django.core.wsgi import get_wsgi_application
|
|
7
|
+
from environs import Env
|
|
8
|
+
from gunicorn.app.wsgiapp import ( # type: ignore[import-untyped]
|
|
9
|
+
WSGIApplication as GunicornWSGIApplication,
|
|
10
|
+
)
|
|
11
|
+
from gunicorn.config import Config # type: ignore[import-untyped]
|
|
12
|
+
|
|
13
|
+
env = Env()
|
|
14
|
+
|
|
15
|
+
DEFAULT_ACCESS_LOG_FORMAT = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %({origin}i)s %({access-control-allow-origin}o)s'
|
|
16
|
+
GUNICORN_FLAGSMITH_DEFAULTS = {
|
|
17
|
+
"access_log_format": env.str("ACCESS_LOG_FORMAT", DEFAULT_ACCESS_LOG_FORMAT),
|
|
18
|
+
"accesslog": env.str("ACCESS_LOG_LOCATION", os.devnull),
|
|
19
|
+
"bind": "0.0.0.0:8000",
|
|
20
|
+
"config": "python:common.gunicorn.conf",
|
|
21
|
+
"logger_class": "common.gunicorn.logging.GunicornJsonCapableLogger",
|
|
22
|
+
"statsd_prefix": "flagsmith.api",
|
|
23
|
+
"threads": env.int("GUNICORN_THREADS", 1),
|
|
24
|
+
"timeout": env.int("GUNICORN_TIMEOUT", 30),
|
|
25
|
+
"worker_class": "sync",
|
|
26
|
+
"workers": env.int("GUNICORN_WORKERS", 1),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DjangoWSGIApplication(GunicornWSGIApplication): # type: ignore[misc]
|
|
31
|
+
def __init__(self, options: dict[str, Any] | None) -> None:
|
|
32
|
+
self.options = {
|
|
33
|
+
key: value for key, value in (options or {}).items() if value is not None
|
|
34
|
+
}
|
|
35
|
+
super().__init__()
|
|
36
|
+
|
|
37
|
+
def load_config(self) -> None:
|
|
38
|
+
cfg_settings = self.cfg.settings
|
|
39
|
+
options_items = (
|
|
40
|
+
(key, value)
|
|
41
|
+
for key, value in {**GUNICORN_FLAGSMITH_DEFAULTS, **self.options}.items()
|
|
42
|
+
if key in cfg_settings
|
|
43
|
+
)
|
|
44
|
+
for key, value in options_items:
|
|
45
|
+
self.cfg.set(key.lower(), value)
|
|
46
|
+
self.load_config_from_module_name_or_filename(self.cfg.config)
|
|
47
|
+
|
|
48
|
+
def load_wsgiapp(self) -> WSGIHandler:
|
|
49
|
+
return get_wsgi_application()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
53
|
+
gunicorn_group = parser.add_argument_group("gunicorn")
|
|
54
|
+
_config = Config()
|
|
55
|
+
keys = sorted(_config.settings, key=_config.settings.__getitem__)
|
|
56
|
+
for key in keys:
|
|
57
|
+
_config.settings[key].add_option(gunicorn_group)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_server(options: dict[str, Any] | None = None) -> None:
|
|
61
|
+
DjangoWSGIApplication(options).run()
|