Skip to content

Commit c9c4935

Browse files
feat: Generate metrics documentation (#65)
fix(tests): `clear_lru_caches` fixture conflicts with `saas_mode`/`enterprise_mode` pytest markers feat(test-tools): Add `snapshot` fixture for snapshot testing --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8872fb8 commit c9c4935

File tree

17 files changed

+321
-32
lines changed

17 files changed

+321
-32
lines changed

settings/dev.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88

99
env = Env()
1010

11+
TEMPLATES = [
12+
{
13+
"BACKEND": "django.template.backends.django.DjangoTemplates",
14+
"DIRS": ["templates"],
15+
"APP_DIRS": True,
16+
},
17+
]
18+
1119
# Settings expected by `mypy_django_plugin`
1220
AWS_SES_REGION_ENDPOINT: str
1321
SEGMENT_RULES_CONDITIONS_LIMIT: int
@@ -48,7 +56,8 @@
4856
TASK_DELETE_RETENTION_DAYS = 15
4957
TASK_DELETE_RUN_EVERY = timedelta(days=1)
5058
TASK_DELETE_RUN_TIME = time(5, 0, 0)
51-
TASK_PROCESSOR_MODE = False
59+
TASK_PROCESSOR_MODE = env.bool("RUN_BY_PROCESSOR", default=False)
60+
DOCGEN_MODE = env.bool("DOCGEN_MODE", default=False)
5261
TASK_RUN_METHOD = TaskRunMethod.TASK_PROCESSOR
5362

5463
# Avoid models.W042 warnings

src/common/core/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
5252
)
5353
os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_multiproc_dir_name
5454

55+
if "docgen" in sys.argv:
56+
os.environ["DOCGEN_MODE"] = "true"
57+
5558
if "task-processor" in sys.argv:
5659
# A hacky way to signal we're not running the API
5760
os.environ["RUN_BY_PROCESSOR"] = "true"
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from operator import itemgetter
2+
from typing import Any, Callable
3+
4+
import prometheus_client
5+
from django.core.management import BaseCommand, CommandParser
6+
from django.template.loader import get_template
7+
from django.utils.module_loading import autodiscover_modules
8+
from prometheus_client.metrics import MetricWrapperBase
9+
10+
11+
class Command(BaseCommand):
12+
help = "Generate documentation for the Flagsmith codebase."
13+
14+
def add_arguments(self, parser: CommandParser) -> None:
15+
subparsers = parser.add_subparsers(
16+
title="sub-commands",
17+
required=True,
18+
)
19+
20+
metric_parser = subparsers.add_parser(
21+
"metrics",
22+
help="Generate metrics documentation.",
23+
)
24+
metric_parser.set_defaults(handle_method=self.handle_metrics)
25+
26+
def initialise(self) -> None:
27+
from common.gunicorn import metrics # noqa: F401
28+
29+
autodiscover_modules(
30+
"metrics",
31+
)
32+
33+
def handle(
34+
self,
35+
*args: Any,
36+
handle_method: Callable[..., None],
37+
**options: Any,
38+
) -> None:
39+
self.initialise()
40+
handle_method(*args, **options)
41+
42+
def handle_metrics(self, *args: Any, **options: Any) -> None:
43+
template = get_template("docgen-metrics.md")
44+
45+
flagsmith_metrics = sorted(
46+
(
47+
{
48+
"name": collector._name,
49+
"documentation": collector._documentation,
50+
"labels": collector._labelnames,
51+
"type": collector._type,
52+
}
53+
for collector in prometheus_client.REGISTRY._collector_to_names
54+
if isinstance(collector, MetricWrapperBase)
55+
),
56+
key=itemgetter("name"),
57+
)
58+
59+
self.stdout.write(
60+
template.render(
61+
context={"flagsmith_metrics": flagsmith_metrics},
62+
)
63+
)

src/common/core/metrics.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import prometheus_client
2+
from django.conf import settings
23

34
from common.core.utils import get_version_info
45

56
flagsmith_build_info = prometheus_client.Gauge(
67
"flagsmith_build_info",
7-
"Flagsmith version and build information",
8+
"Flagsmith version and build information.",
89
["ci_commit_sha", "version"],
910
multiprocess_mode="livemax",
1011
)
@@ -20,4 +21,5 @@ def advertise() -> None:
2021
).set(1)
2122

2223

23-
advertise()
24+
if not settings.DOCGEN_MODE:
25+
advertise()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
title: Metrics
3+
---
4+
5+
## Prometheus
6+
7+
To enable the Prometheus `/metrics` endpoint, set the `PROMETHEUS_ENABLED` environment variable to `true`.
8+
9+
The metrics provided by Flagsmith are described below.
10+
11+
{% for metric in flagsmith_metrics %}
12+
### `{{ metric.name }}`
13+
14+
{{ metric.type|title }}.
15+
16+
{{ metric.documentation }}
17+
18+
Labels:
19+
{% for label in metric.labels %} - `{{ label }}`
20+
{% endfor %}{% endfor %}

src/common/gunicorn/metrics.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,17 @@
66

77
flagsmith_http_server_requests_total = prometheus_client.Counter(
88
"flagsmith_http_server_requests_total",
9-
"Total number of HTTP requests",
9+
"Total number of HTTP requests.",
1010
["route", "method", "response_status"],
1111
)
1212
flagsmith_http_server_request_duration_seconds = Histogram(
1313
"flagsmith_http_server_request_duration_seconds",
14-
"HTTP request duration in seconds",
14+
"HTTP request duration in seconds.",
1515
["route", "method", "response_status"],
1616
)
1717
flagsmith_http_server_response_size_bytes = Histogram(
1818
"flagsmith_http_server_response_size_bytes",
19-
"HTTP response size in bytes",
19+
"HTTP response size in bytes.",
2020
["route", "method", "response_status"],
2121
buckets=getattr(
2222
settings,

src/common/test_tools/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
from common.test_tools.types import AssertMetricFixture
1+
from common.test_tools.types import AssertMetricFixture, SnapshotFixture
22

3-
__all__ = ("AssertMetricFixture",)
3+
__all__ = (
4+
"AssertMetricFixture",
5+
"SnapshotFixture",
6+
)

src/common/test_tools/plugin.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@
55
from prometheus_client.metrics import MetricWrapperBase
66
from pyfakefs.fake_filesystem import FakeFilesystem
77

8-
from common.test_tools.types import AssertMetricFixture
8+
from common.test_tools.types import AssertMetricFixture, Snapshot, SnapshotFixture
9+
10+
11+
def pytest_addoption(parser: pytest.Parser) -> None:
12+
group = parser.getgroup("snapshot")
13+
group.addoption(
14+
"--snapshot-update",
15+
action="store_true",
16+
help="Update snapshot files instead of testing against them.",
17+
)
918

1019

1120
def assert_metric_impl() -> Generator[AssertMetricFixture, None, None]:
@@ -68,3 +77,30 @@ def flagsmith_markers_marked(
6877
request.getfixturevalue("saas_mode")
6978
if marker.name == "enterprise_mode":
7079
request.getfixturevalue("enterprise_mode")
80+
81+
82+
@pytest.fixture
83+
def snapshot(request: pytest.FixtureRequest) -> SnapshotFixture:
84+
"""
85+
Retrieve a `Snapshot` object getter for the current test.
86+
The snapshot is stored in the `snapshots` directory next to the test file.
87+
88+
Snapshot files are named after the test function name (+ ".txt") by default.
89+
If a name is provided to the getter, the snapshot will be stored in a file with that name.
90+
The name is relative to the `snapshots` directory.
91+
92+
When `--snapshot-update` is provided to `pytest`:
93+
- The snapshot will be created if it does not exist.
94+
- If the comparison is false, the snapshot will be updated with the string it's being compared to in the test,
95+
and the test will be marked as expected to fail.
96+
"""
97+
for_update = request.config.getoption("--snapshot-update")
98+
snapshot_dir = request.path.parent / "snapshots"
99+
snapshot_dir.mkdir(exist_ok=True)
100+
101+
def _get_snapshot(name: str = "") -> Snapshot:
102+
snapshot_name = name or f"{request.node.name}.txt"
103+
snapshot_path = snapshot_dir / snapshot_name
104+
return Snapshot(snapshot_path, for_update=for_update)
105+
106+
return _get_snapshot

src/common/test_tools/types.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from pathlib import Path
12
from typing import Protocol
23

4+
import pytest
5+
36

47
class AssertMetricFixture(Protocol):
58
def __call__(
@@ -9,3 +12,35 @@ def __call__(
912
labels: dict[str, str],
1013
value: float | int,
1114
) -> None: ...
15+
16+
17+
class SnapshotFixture(Protocol):
18+
def __call__(self, name: str = "") -> "Snapshot": ...
19+
20+
21+
class Snapshot:
22+
"""
23+
Read contents of `path` and make them available for comparison via the `==` operator.
24+
If the contents are different, and `Snapshot` initialised in update mode,
25+
(e.g. by running `pytest` with `--snapshot-update`), write the new contents to `path`.
26+
"""
27+
28+
def __init__(self, path: Path, for_update: bool) -> None:
29+
self.path = path
30+
mode = "r" if not for_update else "w+"
31+
self.content: str = open(path, encoding="utf-8", mode=mode).read()
32+
self.for_update = for_update
33+
34+
def __eq__(self, other: object) -> bool:
35+
if self.content == other:
36+
return True
37+
if self.for_update and isinstance(other, str):
38+
with open(self.path, "w", encoding="utf-8") as f:
39+
f.write(other)
40+
pytest.xfail(reason=f"Snapshot updated: {self.path}")
41+
return False
42+
43+
def __str__(self) -> str:
44+
return self.content
45+
46+
__repr__ = __str__

src/task_processor/metrics.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@
55

66
flagsmith_task_processor_enqueued_tasks_total = prometheus_client.Counter(
77
"flagsmith_task_processor_enqueued_tasks_total",
8-
"Total number of enqueued tasks",
8+
"Total number of enqueued tasks.",
99
["task_identifier"],
1010
)
1111

12-
if settings.TASK_PROCESSOR_MODE:
12+
if settings.DOCGEN_MODE or settings.TASK_PROCESSOR_MODE:
1313
flagsmith_task_processor_finished_tasks_total = prometheus_client.Counter(
1414
"flagsmith_task_processor_finished_tasks_total",
15-
"Total number of finished tasks",
15+
"Total number of finished tasks. Only collected by Task Processor. `task_type` label is either `recurring` or `standard`.",
1616
["task_identifier", "task_type", "result"],
1717
)
1818
flagsmith_task_processor_task_duration_seconds = Histogram(
1919
"flagsmith_task_processor_task_duration_seconds",
20-
"Task processor task duration in seconds",
20+
"Task processor task duration in seconds. Only collected by Task Processor. `task_type` label is either `recurring` or `standard`.",
2121
["task_identifier", "task_type", "result"],
2222
)

0 commit comments

Comments
 (0)