Skip to content

Commit 3d29dcd

Browse files
committed
Adds metrics helper test fixture
1 parent 8694769 commit 3d29dcd

3 files changed

Lines changed: 127 additions & 30 deletions

File tree

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import requests
99
import yaml
1010

11+
from metrics import MetricsClient
1112
from server import FileActivityService
1213

1314

@@ -132,6 +133,13 @@ def fact_config(request, monitored_dir, logs_dir):
132133
config_file.close()
133134

134135

136+
@pytest.fixture
137+
def metrics(fact_config):
138+
"""Client for taking metrics snapshots from the FACT endpoint."""
139+
config, _ = fact_config
140+
return MetricsClient(config['endpoint']['address'])
141+
142+
135143
@pytest.fixture
136144
def test_container(request, docker_client, monitored_dir, ignored_dir):
137145
"""

tests/metrics.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import re
2+
3+
import requests
4+
5+
6+
class MetricsSnapshot:
7+
"""
8+
A parsed snapshot of Prometheus/OpenMetrics metrics.
9+
10+
Supports querying by metric name and labels:
11+
12+
ss = metrics.snapshot()
13+
assert ss.get("rate_limiter_events", label="Dropped") == 5
14+
assert ss.get("bpf_events", label="Added") > 0
15+
16+
Metric names are matched without the "stackrox_fact_" prefix and
17+
"_total" counter suffix, so "rate_limiter_events" matches
18+
"stackrox_fact_rate_limiter_events_total".
19+
"""
20+
21+
_PREFIX = "stackrox_fact_"
22+
_TOTAL_SUFFIX = "_total"
23+
_LINE_RE = re.compile(
24+
r'^(?P<name>\S+?)(?:\{(?P<labels>[^}]*)\})?\s+(?P<value>\S+)$'
25+
)
26+
_LABEL_RE = re.compile(r'(\w+)="([^"]*)"')
27+
28+
def __init__(self, text):
29+
self._entries = []
30+
for line in text.splitlines():
31+
if line.startswith('#') or not line.strip():
32+
continue
33+
34+
m = self._LINE_RE.match(line)
35+
if not m:
36+
continue
37+
38+
name, raw, labels = m.group('name', 'value', 'labels')
39+
40+
value = float(raw) if '.' in raw else int(raw)
41+
labels = dict(self._LABEL_RE.findall(labels or ''))
42+
43+
self._entries.append((name, labels, value))
44+
45+
@classmethod
46+
def _normalize(cls, name):
47+
if name.startswith(cls._PREFIX):
48+
name = name[len(cls._PREFIX):]
49+
if name.endswith(cls._TOTAL_SUFFIX):
50+
name = name[:-len(cls._TOTAL_SUFFIX)]
51+
return name
52+
53+
def get(self, metric, **labels):
54+
"""
55+
Get the value of a metric, optionally filtered by labels.
56+
57+
Args:
58+
metric: Metric name, with or without the "stackrox_fact_"
59+
prefix and "_total" suffix.
60+
**labels: Label key=value pairs to match.
61+
62+
Returns:
63+
The metric value as int or float.
64+
65+
Raises:
66+
KeyError: If no matching metric is found.
67+
ValueError: If multiple metrics match.
68+
"""
69+
target = self._normalize(metric)
70+
matches = []
71+
for name, entry_labels, value in self._entries:
72+
if self._normalize(name) != target:
73+
continue
74+
if all(entry_labels.get(k) == v for k, v in labels.items()):
75+
matches.append(value)
76+
77+
if not matches:
78+
label_desc = ', '.join(f'{k}="{v}"' for k, v in labels.items())
79+
key = f'{metric}{{{label_desc}}}' if label_desc else metric
80+
available = '\n '.join(
81+
f'{n} {ls} = {v}' for n, ls, v in self._entries
82+
)
83+
raise KeyError(
84+
f'metric {key!r} not found. Available:\n {available}'
85+
)
86+
if len(matches) > 1:
87+
raise ValueError(
88+
f'{metric} matched {len(matches)} entries; use labels to '
89+
f'narrow the result'
90+
)
91+
return matches[0]
92+
93+
def get_all(self, metric, **labels):
94+
"""Like get(), but returns a list of all matching values."""
95+
target = self._normalize(metric)
96+
return [
97+
value for name, entry_labels, value in self._entries
98+
if self._normalize(name) == target
99+
and all(entry_labels.get(k) == v for k, v in labels.items())
100+
]
101+
102+
103+
class MetricsClient:
104+
"""Fetches metrics snapshots from a FACT endpoint."""
105+
106+
def __init__(self, address):
107+
self._url = f'http://{address}/metrics'
108+
109+
def snapshot(self, timeout=30):
110+
resp = requests.get(self._url, timeout=timeout)
111+
resp.raise_for_status()
112+
return MetricsSnapshot(resp.text)

tests/test_rate_limit.py

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from time import sleep
44

55
import pytest
6-
import requests
76
import yaml
87

98
from event import Event, EventType, Process
109

10+
1111
@pytest.fixture
1212
def rate_limited_config(fact, fact_config, monitored_dir):
1313
"""
@@ -23,11 +23,10 @@ def rate_limited_config(fact, fact_config, monitored_dir):
2323
sleep(0.1)
2424
return config, config_file
2525

26-
def test_rate_limit_drops_events(rate_limited_config, monitored_dir, server):
26+
def test_rate_limit_drops_events(rate_limited_config, monitored_dir, server, metrics):
2727
"""
2828
Test that the rate limiter drops events when the rate limit is exceeded.
2929
"""
30-
config, _ = rate_limited_config
3130
num_files = 100
3231
start_time = time.time()
3332

@@ -51,31 +50,19 @@ def test_rate_limit_drops_events(rate_limited_config, monitored_dir, server):
5150
assert received_count < num_files, \
5251
f'Expected rate limiting to drop some events, but received all {received_count}'
5352

54-
metrics_response = requests.get(f'http://{config["endpoint"]["address"]}/metrics')
55-
assert metrics_response.status_code == 200
56-
57-
metrics_text = metrics_response.text
58-
assert 'rate_limiter_events' in metrics_text, 'rate_limiter_events metric not found'
59-
60-
dropped_count = 0
61-
for line in metrics_text.split('\n'):
62-
if 'rate_limiter_events' in line and 'label="Dropped"' in line:
63-
parts = line.split()
64-
if len(parts) >= 2:
65-
dropped_count = int(parts[1])
66-
break
53+
ss = metrics.snapshot()
54+
dropped_count = ss.get("rate_limiter_events", label="Dropped")
6755

6856
assert dropped_count > 0, 'Expected rate limiter to report dropped events in metrics'
6957

7058
total_accounted = received_count + dropped_count
7159

7260
assert total_accounted == num_files, 'Expected rate limiter to see all events'
7361

74-
def test_rate_limit_unlimited(monitored_dir, server, fact_config):
62+
def test_rate_limit_unlimited(monitored_dir, server, metrics):
7563
"""
7664
Test that the default config (rate_limit=0) allows all events through.
7765
"""
78-
config, _ = fact_config
7966
num_files = 20
8067
events = []
8168
process = Process.from_proc()
@@ -90,18 +77,8 @@ def test_rate_limit_unlimited(monitored_dir, server, fact_config):
9077

9178
server.wait_events(events)
9279

93-
metrics_response = requests.get(f'http://{config["endpoint"]["address"]}/metrics')
94-
assert metrics_response.status_code == 200
95-
96-
metrics_text = metrics_response.text
97-
98-
dropped_count = 0
99-
for line in metrics_text.split('\n'):
100-
if 'rate_limiter_events' in line and 'label="Dropped"' in line:
101-
parts = line.split()
102-
if len(parts) >= 2:
103-
dropped_count = int(parts[1])
104-
break
80+
ss = metrics.snapshot()
81+
dropped_count = ss.get("rate_limiter_events", label="Dropped")
10582

10683
assert dropped_count == 0, \
10784
f'Expected no dropped events with unlimited rate limiting, but got {dropped_count}'

0 commit comments

Comments
 (0)