flagsmith-common 3.3.0__tar.gz → 3.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/PKG-INFO +24 -1
  2. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/README.md +20 -0
  3. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/pyproject.toml +6 -1
  4. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/constants.py +2 -0
  5. flagsmith_common-3.5.0/src/common/core/logging.py +181 -0
  6. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/main.py +20 -3
  7. flagsmith_common-3.5.0/src/common/core/sentry.py +22 -0
  8. flagsmith_common-3.5.0/src/common/gunicorn/logging.py +66 -0
  9. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/gunicorn/metrics.py +1 -6
  10. flagsmith_common-3.5.0/src/common/gunicorn/processors.py +74 -0
  11. flagsmith_common-3.5.0/src/common/lint_tests.py +279 -0
  12. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/prometheus/utils.py +1 -4
  13. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/dynamodb.py +7 -1
  14. flagsmith_common-3.3.0/src/common/core/logging.py +0 -24
  15. flagsmith_common-3.3.0/src/common/gunicorn/logging.py +0 -120
  16. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/LICENSE +0 -0
  17. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/__init__.py +0 -0
  18. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/__init__.py +0 -0
  19. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/app.py +0 -0
  20. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/cli/__init__.py +0 -0
  21. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/cli/healthcheck.py +0 -0
  22. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/management/__init__.py +0 -0
  23. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/__init__.py +0 -0
  24. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/docgen.py +0 -0
  25. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/start.py +0 -0
  26. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/waitfordb.py +0 -0
  27. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/metrics.py +0 -0
  28. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/middleware.py +0 -0
  29. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/templates/docgen-metrics.md +0 -0
  30. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/urls.py +0 -0
  31. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/utils.py +0 -0
  32. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/core/views.py +0 -0
  33. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/environments/permissions.py +0 -0
  34. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/features/__init__.py +0 -0
  35. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/features/multivariate/__init__.py +0 -0
  36. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/features/multivariate/serializers.py +0 -0
  37. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/features/serializers.py +0 -0
  38. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/features/versioning/__init__.py +0 -0
  39. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/features/versioning/serializers.py +0 -0
  40. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/gunicorn/__init__.py +0 -0
  41. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/gunicorn/conf.py +0 -0
  42. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/gunicorn/constants.py +0 -0
  43. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/gunicorn/metrics_server.py +0 -0
  44. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/gunicorn/middleware.py +0 -0
  45. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/gunicorn/utils.py +0 -0
  46. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/migrations/__init__.py +0 -0
  47. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/migrations/helpers/__init__.py +0 -0
  48. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  49. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/organisations/permissions.py +0 -0
  50. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/projects/permissions.py +0 -0
  51. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/prometheus/__init__.py +0 -0
  52. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/py.typed +0 -0
  53. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/test_tools/__init__.py +0 -0
  54. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/test_tools/plugin.py +0 -0
  55. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/test_tools/types.py +0 -0
  56. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/test_tools/utils.py +0 -0
  57. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/common/types.py +0 -0
  58. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/__init__.py +0 -0
  59. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/api.py +0 -0
  60. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/constants.py +0 -0
  61. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/py.typed +0 -0
  62. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
  63. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/types.py +0 -0
  64. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/utils.py +0 -0
  65. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/validators.py +0 -0
  66. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/__init__.py +0 -0
  67. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/admin.py +0 -0
  68. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/apps.py +0 -0
  69. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/decorators.py +0 -0
  70. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/exceptions.py +0 -0
  71. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/health.py +0 -0
  72. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/managers.py +0 -0
  73. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/metrics.py +0 -0
  74. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0001_initial.py +0 -0
  75. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  76. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  77. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  78. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  79. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  80. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  81. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  82. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +0 -0
  83. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
  84. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +0 -0
  85. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  86. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  87. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/__init__.py +0 -0
  88. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  89. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  90. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  91. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  92. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  93. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/__init__.py +0 -0
  94. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/models.py +0 -0
  95. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/monitoring.py +0 -0
  96. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/processor.py +0 -0
  97. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/py.typed +0 -0
  98. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/routers.py +0 -0
  99. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/serializers.py +0 -0
  100. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/task_registry.py +0 -0
  101. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/task_run_method.py +0 -0
  102. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/tasks.py +0 -0
  103. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/threads.py +0 -0
  104. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/types.py +0 -0
  105. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/urls.py +0 -0
  106. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/utils.py +0 -0
  107. {flagsmith_common-3.3.0 → flagsmith_common-3.5.0}/src/task_processor/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-common
3
- Version: 3.3.0
3
+ Version: 3.5.0
4
4
  Summary: Flagsmith's common library
5
5
  Author: Matthew Elwell, Gagan Trivedi, Kim Gustyr, Zach Aysan, Francesco Lo Franco, Rodrigo López Dato, Evandro Myller, Wadii Zaim
6
6
  License-Expression: BSD-3-Clause
@@ -20,7 +20,10 @@ Requires-Dist: gunicorn>=19.1 ; extra == 'common-core'
20
20
  Requires-Dist: prometheus-client>=0.0.16 ; extra == 'common-core'
21
21
  Requires-Dist: psycopg2-binary>=2.9,<3 ; extra == 'common-core'
22
22
  Requires-Dist: requests ; extra == 'common-core'
23
+ Requires-Dist: sentry-sdk>=2.0.0,<3.0.0 ; extra == 'common-core'
23
24
  Requires-Dist: simplejson>=3,<4 ; extra == 'common-core'
25
+ Requires-Dist: structlog>=24.4,<26 ; extra == 'common-core'
26
+ Requires-Dist: typing-extensions ; extra == 'common-core'
24
27
  Requires-Dist: simplejson ; extra == 'flagsmith-schemas'
25
28
  Requires-Dist: typing-extensions ; extra == 'flagsmith-schemas'
26
29
  Requires-Dist: flagsmith-flag-engine>6 ; extra == 'flagsmith-schemas'
@@ -81,6 +84,26 @@ This enables the `route` label for Prometheus HTTP metrics.
81
84
 
82
85
  5. To enable the `/metrics` endpoint, set the `PROMETHEUS_ENABLED` setting to `True`.
83
86
 
87
+ ### Pre-commit hooks
88
+
89
+ This repo provides a [`flagsmith-lint-tests`](.pre-commit-hooks.yaml) hook that enforces test conventions:
90
+
91
+ - **FT001**: No module-level `Test*` classes — use function-based tests
92
+ - **FT002**: No `import unittest` / `from unittest import TestCase` — use pytest (`unittest.mock` is fine)
93
+ - **FT003**: Test names must follow `test_{subject}__{condition}__{expected}`
94
+ - **FT004**: Test bodies must contain `# Given`, `# When`, and `# Then` comments
95
+
96
+ To use in your repo, add to `.pre-commit-config.yaml`:
97
+
98
+ ```yaml
99
+ - repo: https://github.com/Flagsmith/flagsmith-common
100
+ rev: main
101
+ hooks:
102
+ - id: flagsmith-lint-tests
103
+ ```
104
+
105
+ Use `# noqa: FT003` (or any code) inline to suppress individual violations.
106
+
84
107
  ### Test tools
85
108
 
86
109
  #### Fixtures
@@ -35,6 +35,26 @@ This enables the `route` label for Prometheus HTTP metrics.
35
35
 
36
36
  5. To enable the `/metrics` endpoint, set the `PROMETHEUS_ENABLED` setting to `True`.
37
37
 
38
+ ### Pre-commit hooks
39
+
40
+ This repo provides a [`flagsmith-lint-tests`](.pre-commit-hooks.yaml) hook that enforces test conventions:
41
+
42
+ - **FT001**: No module-level `Test*` classes — use function-based tests
43
+ - **FT002**: No `import unittest` / `from unittest import TestCase` — use pytest (`unittest.mock` is fine)
44
+ - **FT003**: Test names must follow `test_{subject}__{condition}__{expected}`
45
+ - **FT004**: Test bodies must contain `# Given`, `# When`, and `# Then` comments
46
+
47
+ To use in your repo, add to `.pre-commit-config.yaml`:
48
+
49
+ ```yaml
50
+ - repo: https://github.com/Flagsmith/flagsmith-common
51
+ rev: main
52
+ hooks:
53
+ - id: flagsmith-lint-tests
54
+ ```
55
+
56
+ Use `# noqa: FT003` (or any code) inline to suppress individual violations.
57
+
38
58
  ### Test tools
39
59
 
40
60
  #### Fixtures
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-common"
3
- version = "3.3.0"
3
+ version = "3.5.0"
4
4
  description = "Flagsmith's common library"
5
5
  requires-python = ">=3.11,<4.0"
6
6
  dependencies = []
@@ -19,7 +19,10 @@ optional-dependencies = { test-tools = [
19
19
  "prometheus-client (>=0.0.16)",
20
20
  "psycopg2-binary (>=2.9,<3)",
21
21
  "requests",
22
+ "sentry-sdk (>=2.0.0,<3.0.0)",
22
23
  "simplejson (>=3,<4)",
24
+ "structlog (>=24.4,<26)",
25
+ "typing_extensions",
23
26
  ], task-processor = [
24
27
  "backoff (>=2.2.1,<3.0.0)",
25
28
  "django (>4,<6)",
@@ -60,12 +63,14 @@ Repository = "https://github.com/flagsmith/flagsmith-common"
60
63
 
61
64
  [project.scripts]
62
65
  flagsmith = "common.core.main:main"
66
+ flagsmith-lint-tests = "common.lint_tests:main"
63
67
 
64
68
  [project.entry-points.pytest11]
65
69
  flagsmith-test-tools = "common.test_tools.plugin"
66
70
 
67
71
  [dependency-groups]
68
72
  dev = [
73
+ "diff-cover>=10.2.0",
69
74
  "dj-database-url (>=2.3.0, <3.0.0)",
70
75
  "django-stubs (>=5.1.3, <6.0.0)",
71
76
  "djangorestframework-stubs (>=3.15.3, <4.0.0)",
@@ -1 +1,3 @@
1
1
  DEFAULT_PROMETHEUS_MULTIPROC_DIR_NAME = "flagsmith-prometheus"
2
+
3
+ LOGGING_DEFAULT_ROOT_LOG_LEVEL = "WARNING"
@@ -0,0 +1,181 @@
1
+ import json
2
+ import logging
3
+ import logging.config
4
+ import os
5
+ import sys
6
+ import threading
7
+ from typing import Any
8
+
9
+ import structlog
10
+ import structlog.contextvars
11
+ import structlog.dev
12
+ import structlog.processors
13
+ import structlog.stdlib
14
+ from structlog.typing import EventDict, Processor, WrappedLogger
15
+ from typing_extensions import TypedDict
16
+
17
+ from common.core.constants import LOGGING_DEFAULT_ROOT_LOG_LEVEL
18
+ from common.core.sentry import sentry_processor
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class JsonRecord(TypedDict, extra_items=Any, total=False): # type: ignore[call-arg] # TODO https://github.com/python/mypy/issues/18176
24
+ levelname: str
25
+ message: str
26
+ timestamp: str
27
+ logger_name: str
28
+ pid: int | None
29
+ thread_name: str | None
30
+ exc_info: str
31
+
32
+
33
+ def setup_logging(
34
+ log_level: str = "INFO",
35
+ log_format: str = "generic",
36
+ logging_configuration_file: str | None = None,
37
+ application_loggers: list[str] | None = None,
38
+ extra_foreign_processors: list[Processor] | None = None,
39
+ ) -> None:
40
+ """
41
+ Set up logging for the application.
42
+
43
+ This should be called early, before Django settings are loaded, to ensure
44
+ that all log output is properly formatted from the start.
45
+
46
+ Args:
47
+ log_level: The log level for application code (e.g. "DEBUG", "INFO").
48
+ log_format: Either "generic" or "json".
49
+ logging_configuration_file: Path to a JSON logging config file.
50
+ If provided, this takes precedence over other format options.
51
+ application_loggers: Top-level logger names for application packages.
52
+ These loggers are set to ``log_level`` while the root logger uses
53
+ ``max(log_level, WARNING)`` to suppress noise from third-party
54
+ libraries. If ``log_level`` is DEBUG, everything logs at DEBUG.
55
+ extra_foreign_processors: Additional structlog processors to run in
56
+ the ``foreign_pre_chain`` for stdlib log records (e.g. the
57
+ Gunicorn access log field extractor).
58
+ """
59
+ if logging_configuration_file:
60
+ with open(logging_configuration_file) as f:
61
+ config = json.load(f)
62
+ logging.config.dictConfig(config)
63
+ else:
64
+ log_level_int = logging.getLevelNamesMapping()[log_level.upper()]
65
+ root_level_int = logging.getLevelNamesMapping()[LOGGING_DEFAULT_ROOT_LOG_LEVEL]
66
+ # Suppress third-party noise at WARNING, but if the user requests
67
+ # DEBUG, honour that for the entire process.
68
+ effective_root_level = (
69
+ log_level_int if log_level_int < logging.INFO else root_level_int
70
+ )
71
+
72
+ dict_config: dict[str, Any] = {
73
+ "version": 1,
74
+ "disable_existing_loggers": False,
75
+ "handlers": {
76
+ "console": {
77
+ "class": "logging.StreamHandler",
78
+ "stream": "ext://sys.stdout",
79
+ "level": log_level,
80
+ },
81
+ },
82
+ "root": {
83
+ "level": effective_root_level,
84
+ "handlers": ["console"],
85
+ },
86
+ "loggers": {
87
+ name: {"level": log_level, "handlers": [], "propagate": True}
88
+ for name in application_loggers or []
89
+ },
90
+ }
91
+ logging.config.dictConfig(dict_config)
92
+
93
+ setup_structlog(
94
+ log_format=log_format, extra_foreign_processors=extra_foreign_processors
95
+ )
96
+
97
+
98
+ def map_event_to_json_record(
99
+ logger: WrappedLogger,
100
+ method_name: str,
101
+ event_dict: EventDict,
102
+ ) -> EventDict:
103
+ """Map structlog fields to match :class:`JsonRecord` output schema."""
104
+ # Remove foreign record args injected by pass_foreign_args so they
105
+ # don't leak into the rendered JSON output.
106
+ event_dict.pop("positional_args", None)
107
+ record: JsonRecord = {
108
+ "message": event_dict.pop("event", ""),
109
+ "levelname": event_dict.pop("level", "").upper(),
110
+ "logger_name": event_dict.pop("logger", ""),
111
+ "pid": os.getpid(),
112
+ "thread_name": threading.current_thread().name,
113
+ }
114
+ if "exception" in event_dict:
115
+ record["exc_info"] = event_dict.pop("exception")
116
+ # Merge remaining structlog keys (e.g. extra_key from bind()) with the
117
+ # canonical record so they appear in the JSON output.
118
+ event_dict.update(record)
119
+ return event_dict
120
+
121
+
122
+ def setup_structlog(
123
+ log_format: str,
124
+ extra_foreign_processors: list[Processor] | None = None,
125
+ ) -> None:
126
+ """Configure structlog to route through stdlib logging."""
127
+
128
+ if log_format == "json":
129
+ renderer_processors: list[Processor] = [
130
+ map_event_to_json_record,
131
+ structlog.processors.JSONRenderer(),
132
+ ]
133
+ else:
134
+ colors = sys.stdout.isatty() and structlog.dev._has_colors
135
+ renderer_processors = [
136
+ structlog.dev.ConsoleRenderer(colors=colors),
137
+ ]
138
+
139
+ foreign_pre_chain: list[Processor] = [
140
+ structlog.contextvars.merge_contextvars,
141
+ structlog.stdlib.add_logger_name,
142
+ structlog.stdlib.add_log_level,
143
+ structlog.processors.TimeStamper(fmt="iso"),
144
+ structlog.processors.format_exc_info,
145
+ structlog.stdlib.ExtraAdder(),
146
+ *(extra_foreign_processors or []),
147
+ ]
148
+
149
+ formatter = structlog.stdlib.ProcessorFormatter(
150
+ processors=[
151
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
152
+ *renderer_processors,
153
+ ],
154
+ foreign_pre_chain=foreign_pre_chain,
155
+ pass_foreign_args=True,
156
+ )
157
+
158
+ # Replace the formatter on existing root handlers with ProcessorFormatter.
159
+ root = logging.getLogger()
160
+ for handler in root.handlers:
161
+ handler.setFormatter(formatter)
162
+
163
+ structlog.configure(
164
+ processors=[
165
+ structlog.contextvars.merge_contextvars,
166
+ structlog.stdlib.filter_by_level,
167
+ structlog.stdlib.add_logger_name,
168
+ structlog.stdlib.add_log_level,
169
+ structlog.stdlib.PositionalArgumentsFormatter(),
170
+ structlog.processors.StackInfoRenderer(),
171
+ structlog.processors.UnicodeDecoder(),
172
+ structlog.processors.format_exc_info,
173
+ structlog.processors.TimeStamper(fmt="iso"),
174
+ sentry_processor,
175
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
176
+ ],
177
+ wrapper_class=structlog.stdlib.BoundLogger,
178
+ context_class=dict,
179
+ logger_factory=structlog.stdlib.LoggerFactory(),
180
+ cache_logger_on_first_use=True,
181
+ )
@@ -8,8 +8,13 @@ from tempfile import mkdtemp
8
8
  from django.core.management import (
9
9
  execute_from_command_line as django_execute_from_command_line,
10
10
  )
11
+ from environs import Env
11
12
 
12
13
  from common.core.cli import healthcheck
14
+ from common.core.logging import setup_logging
15
+ from common.gunicorn.processors import make_gunicorn_access_processor
16
+
17
+ env = Env()
13
18
 
14
19
  logger = logging.getLogger(__name__)
15
20
 
@@ -32,7 +37,18 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
32
37
  """
33
38
  ctx = contextlib.ExitStack()
34
39
 
35
- # TODO @khvn26 Move logging setup to here
40
+ # Set up logging early, before Django settings are loaded.
41
+ setup_logging(
42
+ log_level=env.str("LOG_LEVEL", "INFO"),
43
+ log_format=env.str("LOG_FORMAT", "generic"),
44
+ logging_configuration_file=env.str("LOGGING_CONFIGURATION_FILE", None),
45
+ application_loggers=env.list("APPLICATION_LOGGERS", []) or None,
46
+ extra_foreign_processors=[
47
+ make_gunicorn_access_processor(
48
+ env.list("ACCESS_LOG_EXTRA_ITEMS", []) or None,
49
+ ),
50
+ ],
51
+ )
36
52
 
37
53
  # Prometheus multiproc support
38
54
  if not os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
@@ -67,12 +83,13 @@ def execute_from_command_line(argv: list[str]) -> None:
67
83
  "checktaskprocessorthreadhealth": healthcheck.main,
68
84
  }[subcommand]
69
85
  except (IndexError, KeyError):
70
- django_execute_from_command_line(argv)
86
+ logger.info("Invoking Django")
71
87
  else:
72
- subcommand_main(
88
+ return subcommand_main(
73
89
  argv[2:],
74
90
  prog=f"{os.path.basename(argv[0])} {subcommand}",
75
91
  )
92
+ django_execute_from_command_line(argv)
76
93
 
77
94
 
78
95
  def main(argv: list[str] = sys.argv) -> None:
@@ -0,0 +1,22 @@
1
+ import sentry_sdk
2
+ from structlog.typing import EventDict, WrappedLogger
3
+
4
+ _SKIP_CONTEXT_FIELDS = frozenset({"event", "level", "timestamp", "_record"})
5
+
6
+
7
+ def sentry_processor(
8
+ logger: WrappedLogger,
9
+ method_name: str,
10
+ event_dict: EventDict,
11
+ ) -> EventDict:
12
+ """
13
+ Structlog processor that enriches Sentry with structured context and tags.
14
+
15
+ Since structlog routes through stdlib, Sentry's LoggingIntegration
16
+ automatically captures ERROR+ logs as Sentry events. This processor
17
+ adds structured context on top of that.
18
+ """
19
+ context = {k: v for k, v in event_dict.items() if k not in _SKIP_CONTEXT_FIELDS}
20
+ sentry_sdk.set_context("structlog", context)
21
+
22
+ return event_dict
@@ -0,0 +1,66 @@
1
+ import logging
2
+ import os
3
+ from datetime import timedelta
4
+ from typing import Any
5
+
6
+ from gunicorn.config import Config # type: ignore[import-untyped]
7
+ from gunicorn.http.message import Request # type: ignore[import-untyped]
8
+ from gunicorn.http.wsgi import Response # type: ignore[import-untyped]
9
+ from gunicorn.instrument.statsd import ( # type: ignore[import-untyped]
10
+ Statsd as StatsdGunicornLogger,
11
+ )
12
+
13
+ from common.gunicorn import metrics
14
+ from common.gunicorn.utils import get_extra
15
+
16
+
17
+ class PrometheusGunicornLogger(StatsdGunicornLogger): # type: ignore[misc]
18
+ """Gunicorn logger that records Prometheus metrics on each access log entry."""
19
+
20
+ def access(
21
+ self,
22
+ resp: Response,
23
+ req: Request,
24
+ environ: dict[str, Any],
25
+ request_time: timedelta,
26
+ ) -> None:
27
+ super().access(resp, req, environ, request_time)
28
+ duration_seconds = (
29
+ request_time.seconds + float(request_time.microseconds) / 10**6
30
+ )
31
+ labels = {
32
+ # To avoid cardinality explosion, we use a resolved Django route
33
+ # instead of raw path.
34
+ # The Django route is set by `RouteLoggerMiddleware`.
35
+ "route": get_extra(environ=environ, key="route") or "",
36
+ "method": environ.get("REQUEST_METHOD") or "",
37
+ "response_status": resp.status_code,
38
+ }
39
+ metrics.flagsmith_http_server_request_duration_seconds.labels(**labels).observe(
40
+ duration_seconds
41
+ )
42
+ metrics.flagsmith_http_server_requests_total.labels(**labels).inc()
43
+ metrics.flagsmith_http_server_response_size_bytes.labels(**labels).observe(
44
+ getattr(resp, "sent", 0),
45
+ )
46
+
47
+
48
+ class GunicornJsonCapableLogger(PrometheusGunicornLogger):
49
+ """Gunicorn logger that integrates with the application logging setup."""
50
+
51
+ def setup(self, cfg: Config) -> None:
52
+ super().setup(cfg)
53
+
54
+ # Error log always propagates to root.
55
+ for handler in self.error_log.handlers[:]:
56
+ self.error_log.removeHandler(handler)
57
+ self.error_log.propagate = True
58
+
59
+ # In JSON mode, replace the access log formatter with the root's
60
+ # ProcessorFormatter. In generic mode, keep Gunicorn's CLF formatter.
61
+ # The handler itself is preserved so ACCESS_LOG_LOCATION is respected.
62
+ root_handlers = logging.getLogger().handlers
63
+ if os.environ.get("LOG_FORMAT") == "json" and root_handlers:
64
+ root_formatter = root_handlers[0].formatter
65
+ for handler in self.access_log.handlers:
66
+ handler.setFormatter(root_formatter)
@@ -1,5 +1,4 @@
1
1
  import prometheus_client
2
- from django.conf import settings
3
2
 
4
3
  from common.gunicorn.constants import HTTP_SERVER_RESPONSE_SIZE_DEFAULT_BUCKETS
5
4
  from common.prometheus import Histogram
@@ -18,9 +17,5 @@ flagsmith_http_server_response_size_bytes = Histogram(
18
17
  "flagsmith_http_server_response_size_bytes",
19
18
  "HTTP response size in bytes.",
20
19
  ["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
- ),
20
+ buckets=HTTP_SERVER_RESPONSE_SIZE_DEFAULT_BUCKETS,
26
21
  )
@@ -0,0 +1,74 @@
1
+ from datetime import datetime
2
+
3
+ from structlog.typing import EventDict, Processor, WrappedLogger
4
+
5
+ from common.gunicorn.constants import (
6
+ WSGI_EXTRA_SUFFIX_TO_CATEGORY,
7
+ wsgi_extra_key_regex,
8
+ )
9
+
10
+
11
+ def make_gunicorn_access_processor(
12
+ access_log_extra_items: list[str] | None = None,
13
+ ) -> Processor:
14
+ """Create a processor that extracts structured fields from Gunicorn access logs.
15
+
16
+ Gunicorn populates ``record.args`` with a dict of request/response data
17
+ (keyed by format variables like ``h``, ``m``, ``s``, ``U``, etc.). This
18
+ processor detects those records and promotes the data into the event dict
19
+ so it flows through the normal rendering pipeline.
20
+
21
+ Pass the returned processor to :func:`~common.core.logging.setup_logging`
22
+ via ``extra_foreign_processors``.
23
+ """
24
+
25
+ def processor(
26
+ logger: WrappedLogger,
27
+ method_name: str,
28
+ event_dict: EventDict,
29
+ ) -> EventDict:
30
+ record = event_dict.get("_record")
31
+ if record is None or record.name != "gunicorn.access":
32
+ return event_dict
33
+ # Gunicorn passes request data as a dict in record.args.
34
+ # By the time foreign_pre_chain runs, ProcessorFormatter has
35
+ # cleared args on its copy; positional_args has the originals.
36
+ args = event_dict.get("positional_args", record.args)
37
+ if not isinstance(args, dict):
38
+ return event_dict
39
+
40
+ url = args.get("U", "")
41
+ if q := args.get("q"):
42
+ url += f"?{q}"
43
+
44
+ if t := args.get("t"):
45
+ event_dict["time"] = datetime.strptime(
46
+ t, "[%d/%b/%Y:%H:%M:%S %z]"
47
+ ).isoformat()
48
+ event_dict["path"] = url
49
+ event_dict["remote_ip"] = args.get("h", "")
50
+ event_dict["method"] = args.get("m", "")
51
+ event_dict["status"] = str(args.get("s", ""))
52
+ event_dict["user_agent"] = args.get("a", "")
53
+ event_dict["duration_in_ms"] = args.get("M", 0)
54
+ event_dict["response_size_in_bytes"] = args.get("B") or 0
55
+
56
+ if access_log_extra_items:
57
+ for extra_key in access_log_extra_items:
58
+ extra_key_lower = extra_key.lower()
59
+ if (
60
+ (extra_value := args.get(extra_key_lower))
61
+ and (re_match := wsgi_extra_key_regex.match(extra_key_lower))
62
+ and (
63
+ category := WSGI_EXTRA_SUFFIX_TO_CATEGORY.get(
64
+ re_match.group("suffix")
65
+ )
66
+ )
67
+ ):
68
+ event_dict.setdefault(category, {})[re_match.group("key")] = (
69
+ extra_value
70
+ )
71
+
72
+ return event_dict
73
+
74
+ return processor