Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
30e5cfa
added worker max RSS helper with tests + instructions
mmaslov007 Apr 28, 2026
1ef08d0
Merge branch 'Flagsmith:main' into rss-collector
mmaslov007 Apr 28, 2026
b095078
Merge branch 'Flagsmith:main' into rss-collector
mmaslov007 Apr 28, 2026
cc76ffd
Relocated helper testing instructions to relevant /docs folder.
mmaslov007 Apr 28, 2026
46fb1ac
Merge branch 'main' of https://github.com/Flagsmith/flagsmith into rs…
mmaslov007 May 7, 2026
2c8cec4
read worker max RSS from proc status
mmaslov007 May 7, 2026
9145112
Merge pull request #2 from mmaslov007/rss-collector
AAshGray May 7, 2026
b62ffad
created worker metric Prometheus gauge
AAshGray Apr 28, 2026
d3c3691
updated the metrics-catalogue with the flagsmith_worker_rss_byes gauge
AAshGray Apr 29, 2026
56741a3
created worker metric Prometheus gauge
AAshGray Apr 28, 2026
0b49af0
added a background thread to update resource usage and imported it to…
AAshGray May 5, 2026
51386b3
updated new worker_metrics with gauge
AAshGray May 8, 2026
59255ae
fixed duplicate imports
AAshGray May 8, 2026
09b996e
re-arranged update_ and clear_ functions to be adjacent
AAshGray May 8, 2026
6c7b3f6
added unit tests for update_ and clear_worker_metrics
AAshGray May 8, 2026
1738ef7
minor correction to test so mock mirrors gauge syntax more accurately
AAshGray May 8, 2026
b3be154
Merge pull request #1 from mmaslov007/gauge
HumaGitGud May 8, 2026
c7113e5
adjusted gauge description
HumaGitGud May 8, 2026
0a89c75
added return types for mypy to pass
HumaGitGud May 8, 2026
ef716d6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 8, 2026
791500b
Merge pull request #3 from mmaslov007/mypy-fixes
HumaGitGud May 8, 2026
e572124
Merge branch 'Flagsmith:main' into main
mmaslov007 May 8, 2026
4a3a7a6
implemented django worker rss middleware to update memory gauge
HumaGitGud May 8, 2026
55752b0
add WorkerRSSMiddleware in middleware stack
HumaGitGud May 8, 2026
8533a22
implemented tests for middleware
HumaGitGud May 10, 2026
a4cf7c8
Merge pull request #4 from mmaslov007/worker-rss-middleware
HumaGitGud May 10, 2026
058749e
fixed hardcoded line into if statement
HumaGitGud May 13, 2026
202f1c9
Merge branch 'Flagsmith:main' into main
mmaslov007 May 13, 2026
3d00a6d
Merge pull request #5 from mmaslov007/middleware-fix
AAshGray May 13, 2026
c4867d6
docs: document worker RSS metric and add integration test
SketchRudy May 14, 2026
4f516d6
Merge pull request #6 from mmaslov007/story-4-worker-rss-docs
SketchRudy May 14, 2026
859945a
Merge branch 'Flagsmith:main' into main
SketchRudy May 19, 2026
b5cfac8
chore: remove sprint scaffold notes ahead of upstream submission
SketchRudy May 19, 2026
4003433
Merge pull request #7 from mmaslov007/chore/remove-sprint-scaffolds
AAshGray May 19, 2026
3d83f7a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,9 @@

PROMETHEUS_ENABLED = env.bool("PROMETHEUS_ENABLED", False)

if PROMETHEUS_ENABLED:
MIDDLEWARE.append("core.middleware.worker_rss.WorkerRSSMiddleware")

DOCGEN_MODE = env.bool("DOCGEN_MODE", default=False)

REQUIRE_AUTHENTICATION_FOR_API_DOCS = env.bool(
Expand Down
16 changes: 16 additions & 0 deletions api/core/middleware/worker_rss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.http import HttpRequest, HttpResponse

from metrics.worker_metrics import update_worker_metrics


class WorkerRSSMiddleware:
def __init__(self, get_response): # type: ignore[no-untyped-def]
self.get_response = get_response

def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
try:
update_worker_metrics()
except Exception:
pass
return response
86 changes: 86 additions & 0 deletions api/metrics/worker_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import os
from pathlib import Path
from typing import Iterable

import prometheus_client

PROC_SELF_STATUS_PATH = Path("/proc/self/status")
MAX_RSS_KB_TO_BYTES = 1024
MAX_RSS_STATUS_FIELD = "VmHWM"

flagsmith_worker_rss_bytes = prometheus_client.Gauge(
"flagsmith_worker_rss_bytes",
"Maximum RSS (high-water mark) of the worker process in bytes, read from VmHWM in /proc/self/status.",
["pid"],
multiprocess_mode="liveall",
)


def update_worker_metrics() -> None:
"""
Update the RSS gauge with the current worker process high-water mark.
"""
current_pid = os.getpid()

rss_value = get_current_process_max_rss_bytes()
if rss_value is not None:
flagsmith_worker_rss_bytes.labels(pid=str(current_pid)).set(rss_value)


def clear_worker_metrics() -> None:
"""
Clear the RSS memory usage metric for the current worker process.
This should be called when a worker process is shutting down to prevent stale metrics.
"""
current_pid = os.getpid()
try:
flagsmith_worker_rss_bytes.remove(pid=str(current_pid))
except (KeyError, ValueError):
pass


def get_current_process_max_rss_bytes() -> int | None:
try:
proc_status_lines = PROC_SELF_STATUS_PATH.read_text(
encoding="utf-8"
).splitlines()
except (FileNotFoundError, OSError, UnicodeDecodeError):
return None

max_rss_kb = _get_proc_status_memory_kb(proc_status_lines, MAX_RSS_STATUS_FIELD)
if max_rss_kb is None:
return None

return max_rss_kb * MAX_RSS_KB_TO_BYTES


def _get_proc_status_memory_kb(
proc_status_lines: Iterable[str],
field_name: str,
) -> int | None:
for line in proc_status_lines:
name, separator, value = line.strip().partition(":")
if separator and name == field_name:
return _parse_proc_status_memory_kb(value)

return None


def _parse_proc_status_memory_kb(value: str) -> int | None:
parts = value.split()
if len(parts) != 2:
return None

memory_kb_text, unit = parts
if unit != "kB":
return None

try:
memory_kb = int(memory_kb_text)
except ValueError:
return None

if memory_kb < 0:
return None

return memory_kb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import os

from django.conf import settings as django_settings
from django.test import Client, override_settings
from prometheus_client import REGISTRY, generate_latest
from pytest_mock import MockerFixture

from metrics.worker_metrics import clear_worker_metrics


@override_settings(
MIDDLEWARE=[
*django_settings.MIDDLEWARE,
"core.middleware.worker_rss.WorkerRSSMiddleware",
]
)
def test_worker_rss_metric__request_through_middleware__appears_in_prometheus_output(
client: Client,
mocker: MockerFixture,
) -> None:
# Given - deterministic RSS reading so the test is independent of /proc availability
# on macOS/Windows CI runners.
expected_rss = 12_345_678
mocker.patch(
"metrics.worker_metrics.get_current_process_max_rss_bytes",
return_value=expected_rss,
)

# When - any cheap, known-reachable endpoint trips the middleware after response.
response = client.get("/api/v1/swagger.json", HTTP_ACCEPT="application/json")

# Then - the response is unaffected by the middleware, and the gauge is exposed
# with a sample for the current worker's PID via the Prometheus exposition format.
assert response.status_code == 200
output = generate_latest(REGISTRY).decode()
assert "flagsmith_worker_rss_bytes" in output
assert f'pid="{os.getpid()}"' in output


def teardown_function(function: object) -> None:
# Prevent labelled-child leakage to other tests in the same xdist worker by removing
# this PID's sample after each test. Uses the existing module API.
try:
clear_worker_metrics()
except Exception:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from django.http import HttpResponse

from core.middleware.worker_rss import WorkerRSSMiddleware


def test_worker_rss_middleware__any_request__calls_update_after_response(mocker): # type: ignore[no-untyped-def]
# Given
call_order = []

def fake_get_response(request): # type: ignore[no-untyped-def]
call_order.append("handled")
return HttpResponse()

mocker.patch(
"core.middleware.worker_rss.update_worker_metrics",
side_effect=lambda: call_order.append("updated"),
)
middleware = WorkerRSSMiddleware(fake_get_response)

# When
middleware(mocker.MagicMock())

# Then — metric must be updated after the request is handled, not before
assert call_order == ["handled", "updated"]


def test_worker_rss_middleware__any_request__returns_response_unchanged(mocker): # type: ignore[no-untyped-def]
# Given
expected_response = HttpResponse(status=200)
mocker.patch("core.middleware.worker_rss.update_worker_metrics")
middleware = WorkerRSSMiddleware(lambda _request: expected_response)

# When
result = middleware(mocker.MagicMock())

# Then
assert result is expected_response


def test_worker_rss_middleware__update_raises__request_still_completes(mocker): # type: ignore[no-untyped-def]
# Given
expected_response = HttpResponse(status=200)
mocker.patch(
"core.middleware.worker_rss.update_worker_metrics",
side_effect=Exception("metric failure"),
)
middleware = WorkerRSSMiddleware(lambda _request: expected_response)

# When
result = middleware(mocker.MagicMock())

# Then — exception is swallowed, response still returned
assert result is expected_response
Loading
Loading