flagsmith-common 3.4.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.4.0 → flagsmith_common-3.5.0}/PKG-INFO +4 -1
  2. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/pyproject.toml +4 -1
  3. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/constants.py +2 -0
  4. flagsmith_common-3.5.0/src/common/core/logging.py +181 -0
  5. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/main.py +20 -3
  6. flagsmith_common-3.5.0/src/common/core/sentry.py +22 -0
  7. flagsmith_common-3.5.0/src/common/gunicorn/logging.py +66 -0
  8. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/gunicorn/metrics.py +1 -6
  9. flagsmith_common-3.5.0/src/common/gunicorn/processors.py +74 -0
  10. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/prometheus/utils.py +1 -4
  11. flagsmith_common-3.4.0/src/common/core/logging.py +0 -24
  12. flagsmith_common-3.4.0/src/common/gunicorn/logging.py +0 -120
  13. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/LICENSE +0 -0
  14. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/README.md +0 -0
  15. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/__init__.py +0 -0
  16. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/__init__.py +0 -0
  17. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/app.py +0 -0
  18. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/cli/__init__.py +0 -0
  19. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/cli/healthcheck.py +0 -0
  20. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/management/__init__.py +0 -0
  21. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/__init__.py +0 -0
  22. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/docgen.py +0 -0
  23. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/start.py +0 -0
  24. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/management/commands/waitfordb.py +0 -0
  25. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/metrics.py +0 -0
  26. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/middleware.py +0 -0
  27. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/templates/docgen-metrics.md +0 -0
  28. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/urls.py +0 -0
  29. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/utils.py +0 -0
  30. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/core/views.py +0 -0
  31. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/environments/permissions.py +0 -0
  32. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/features/__init__.py +0 -0
  33. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/features/multivariate/__init__.py +0 -0
  34. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/features/multivariate/serializers.py +0 -0
  35. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/features/serializers.py +0 -0
  36. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/features/versioning/__init__.py +0 -0
  37. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/features/versioning/serializers.py +0 -0
  38. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/gunicorn/__init__.py +0 -0
  39. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/gunicorn/conf.py +0 -0
  40. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/gunicorn/constants.py +0 -0
  41. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/gunicorn/metrics_server.py +0 -0
  42. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/gunicorn/middleware.py +0 -0
  43. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/gunicorn/utils.py +0 -0
  44. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/lint_tests.py +0 -0
  45. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/migrations/__init__.py +0 -0
  46. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/migrations/helpers/__init__.py +0 -0
  47. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/migrations/helpers/postgres_helpers.py +0 -0
  48. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/organisations/permissions.py +0 -0
  49. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/projects/permissions.py +0 -0
  50. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/prometheus/__init__.py +0 -0
  51. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/py.typed +0 -0
  52. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/test_tools/__init__.py +0 -0
  53. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/test_tools/plugin.py +0 -0
  54. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/test_tools/types.py +0 -0
  55. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/test_tools/utils.py +0 -0
  56. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/common/types.py +0 -0
  57. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/__init__.py +0 -0
  58. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/api.py +0 -0
  59. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/constants.py +0 -0
  60. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/dynamodb.py +0 -0
  61. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/py.typed +0 -0
  62. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/pydantic_types.py +0 -0
  63. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/types.py +0 -0
  64. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/utils.py +0 -0
  65. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/flagsmith_schemas/validators.py +0 -0
  66. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/__init__.py +0 -0
  67. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/admin.py +0 -0
  68. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/apps.py +0 -0
  69. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/decorators.py +0 -0
  70. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/exceptions.py +0 -0
  71. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/health.py +0 -0
  72. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/managers.py +0 -0
  73. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/metrics.py +0 -0
  74. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0001_initial.py +0 -0
  75. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0002_healthcheckmodel.py +0 -0
  76. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0003_add_completed_to_task.py +0 -0
  77. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0004_recreate_task_indexes.py +0 -0
  78. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0005_update_conditional_index_conditions.py +0 -0
  79. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0006_auto_20230221_0802.py +0 -0
  80. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0007_add_is_locked.py +0 -0
  81. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0008_add_get_task_to_process_function.py +0 -0
  82. {flagsmith_common-3.4.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.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0010_task_priority.py +0 -0
  84. {flagsmith_common-3.4.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.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0012_add_locked_at_and_timeout.py +0 -0
  86. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/0013_add_last_picked_at.py +0 -0
  87. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/__init__.py +0 -0
  88. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +0 -0
  89. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0008_get_tasks_to_process.sql +0 -0
  90. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0011_get_tasks_to_process.sql +0 -0
  91. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +0 -0
  92. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +0 -0
  93. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/migrations/sql/__init__.py +0 -0
  94. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/models.py +0 -0
  95. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/monitoring.py +0 -0
  96. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/processor.py +0 -0
  97. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/py.typed +0 -0
  98. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/routers.py +0 -0
  99. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/serializers.py +0 -0
  100. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/task_registry.py +0 -0
  101. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/task_run_method.py +0 -0
  102. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/tasks.py +0 -0
  103. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/threads.py +0 -0
  104. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/types.py +0 -0
  105. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/urls.py +0 -0
  106. {flagsmith_common-3.4.0 → flagsmith_common-3.5.0}/src/task_processor/utils.py +0 -0
  107. {flagsmith_common-3.4.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.4.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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-common"
3
- version = "3.4.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)",
@@ -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
@@ -1,13 +1,10 @@
1
1
  import importlib
2
2
 
3
3
  import prometheus_client
4
- from django.conf import settings
5
4
  from prometheus_client.metrics import MetricWrapperBase
6
5
  from prometheus_client.multiprocess import MultiProcessCollector
7
6
 
8
-
9
- class Histogram(prometheus_client.Histogram):
10
- DEFAULT_BUCKETS = settings.PROMETHEUS_HISTOGRAM_BUCKETS
7
+ Histogram = prometheus_client.Histogram
11
8
 
12
9
 
13
10
  def get_registry() -> prometheus_client.CollectorRegistry:
@@ -1,24 +0,0 @@
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))
@@ -1,120 +0,0 @@
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
- )