Skip to content

Commit 9084ca8

Browse files
committed
feat(runtime): Update pollrate behavior and add tests for it
Signed-off-by: Cagri Yonca <cagri@ibm.com>
1 parent 56f80d6 commit 9084ca8

4 files changed

Lines changed: 138 additions & 69 deletions

File tree

src/instana/collector/helpers/runtime.py

Lines changed: 41 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ def __init__(
4444
self.previous = DictionaryOfStan()
4545
self.previous_rusage = get_resource_usage()
4646

47-
if gc.isenabled():
48-
self.previous_gc_count = gc.get_count()
49-
else:
50-
self.previous_gc_count = None
47+
# Initialise to None so the first collect_metrics call establishes the
48+
# baseline snapshot and reports all-zero deltas. Any GC activity that
49+
# occurs between process start and the first collection is intentionally
50+
# excluded — we only report incremental deltas from the first poll onward.
51+
self.previous_gc_stats = None
5152

5253
def collect_metrics(self, **kwargs: Dict[str, Any]) -> List[Dict[str, Any]]:
5354
plugin_data = dict()
@@ -232,52 +233,27 @@ def _collect_runtime_metrics(
232233

233234
def _collect_gc_metrics(self, plugin_data, with_snapshot):
234235
try:
235-
gc_count = gc.get_count()
236-
gc_threshold = gc.get_threshold()
237-
238-
self.apply_delta(
239-
gc_count[0],
240-
self.previous["data"]["metrics"]["gc"],
241-
plugin_data["data"]["metrics"]["gc"],
242-
"collect0",
243-
with_snapshot,
244-
)
245-
self.apply_delta(
246-
gc_count[1],
247-
self.previous["data"]["metrics"]["gc"],
248-
plugin_data["data"]["metrics"]["gc"],
249-
"collect1",
250-
with_snapshot,
251-
)
252-
self.apply_delta(
253-
gc_count[2],
254-
self.previous["data"]["metrics"]["gc"],
255-
plugin_data["data"]["metrics"]["gc"],
256-
"collect2",
257-
with_snapshot,
258-
)
259-
260-
self.apply_delta(
261-
gc_threshold[0],
262-
self.previous["data"]["metrics"]["gc"],
263-
plugin_data["data"]["metrics"]["gc"],
264-
"threshold0",
265-
with_snapshot,
266-
)
267-
self.apply_delta(
268-
gc_threshold[1],
269-
self.previous["data"]["metrics"]["gc"],
270-
plugin_data["data"]["metrics"]["gc"],
271-
"threshold1",
272-
with_snapshot,
273-
)
274-
self.apply_delta(
275-
gc_threshold[2],
276-
self.previous["data"]["metrics"]["gc"],
277-
plugin_data["data"]["metrics"]["gc"],
278-
"threshold2",
279-
with_snapshot,
280-
)
236+
gc_stats = gc.get_stats()
237+
if self.previous_gc_stats is None:
238+
# First call: establish baseline, report all-zero deltas so the
239+
# snapshot payload carries zeros rather than the cumulative counts
240+
# that accumulated since process start (which are not meaningful
241+
# as deltas).
242+
self.previous_gc_stats = gc_stats
243+
return
244+
245+
# Use a plain dict as the staging target so that accessing it never
246+
# auto-creates keys in plugin_data (DictionaryOfStan creates keys on
247+
# read, which would leave an empty "gc": {} even when nothing changed).
248+
staging = {}
249+
prev_gc = self.previous["data"]["metrics"]["gc"]
250+
for i, (stat, prev_stat) in enumerate(zip(gc_stats, self.previous_gc_stats)):
251+
for key in ("collections", "collected", "uncollectable"):
252+
delta = stat[key] - prev_stat.get(key, 0)
253+
self.apply_delta(delta, prev_gc, staging, f"{key}{i}", with_snapshot)
254+
if staging:
255+
plugin_data["data"]["metrics"]["gc"].update(staging)
256+
self.previous_gc_stats = gc_stats
281257
except Exception:
282258
logger.debug("_collect_gc_metrics", exc_info=True)
283259

@@ -333,6 +309,21 @@ def _collect_runtime_snapshot(
333309
snapshot_payload["versions"] = self.gather_python_packages()
334310
snapshot_payload["iv"] = VERSION
335311

312+
# Inform filler of the configured poll_rate so that entity expiry is
313+
# scaled correctly (presenceExpirySeconds = 20 * poll_rate).
314+
# The backend reads this via SnapshotExtracting.describePollRateSnapshot()
315+
# which looks for the top-level "pollRate" key in the payload.
316+
poll_rate = getattr(
317+
getattr(self.collector, "agent", None),
318+
"options",
319+
None,
320+
)
321+
if poll_rate is not None:
322+
poll_rate = getattr(poll_rate, "poll_rate", 1)
323+
else:
324+
poll_rate = 1
325+
plugin_data["data"]["pollRate"] = poll_rate
326+
336327
if is_autowrapt_instrumented():
337328
snapshot_payload["m"] = "Autowrapt"
338329
elif is_webhook_instrumented():

src/instana/options.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ class StandardOptions(BaseOptions):
360360
AGENT_DEFAULT_HOST = "localhost"
361361
AGENT_DEFAULT_PORT = 42699
362362
DEFAULT_POLL_RATE = 1
363-
MAX_POLL_RATE = 5
363+
VALID_POLL_RATES = [1, 5, 10, 20, 30, 60, 120, 180, 240, 300, 360, 420, 480, 540, 600]
364364

365365
def __init__(self, **kwds: dict[str, Any]) -> None:
366366
super(StandardOptions, self).__init__()
@@ -543,7 +543,11 @@ def set_disable_tracing(self, tracing_config: Sequence[dict[str, Any]]) -> None:
543543
self.enabled_spans.extend(enabled_spans)
544544

545545
def set_poll_rate(self, plugin_config: dict[str, Any]) -> None:
546-
"""Set poll rate from agent plugin configuration."""
546+
"""Set poll rate from agent plugin configuration.
547+
548+
Normalizes the received value to the nearest valid poll rate in
549+
VALID_POLL_RATES, matching the behaviour of Java PollRateUtil.
550+
"""
547551
poll_rate_value = plugin_config.get("poll_rate")
548552
if poll_rate_value is None:
549553
return
@@ -557,18 +561,17 @@ def set_poll_rate(self, plugin_config: dict[str, Any]) -> None:
557561
self.poll_rate = self.DEFAULT_POLL_RATE
558562
return
559563

560-
if poll_rate in (self.DEFAULT_POLL_RATE, self.MAX_POLL_RATE):
561-
self.poll_rate = poll_rate
562-
logger.debug(
563-
f"Poll rate set to {self.poll_rate} seconds from agent configuration"
564+
if poll_rate <= 0:
565+
self.poll_rate = self.DEFAULT_POLL_RATE
566+
logger.warning(
567+
f"Invalid poll_rate value {poll_rate}, defaulting to {self.DEFAULT_POLL_RATE}"
564568
)
565569
return
566570

571+
self.poll_rate = min(self.VALID_POLL_RATES, key=lambda x: abs(x - poll_rate))
567572
logger.debug(
568-
f"Invalid poll_rate value {poll_rate}, defaulting to "
569-
f"{self.DEFAULT_POLL_RATE}"
573+
f"Poll rate set to {self.poll_rate} seconds from agent configuration"
570574
)
571-
self.poll_rate = self.DEFAULT_POLL_RATE
572575

573576
def set_from(self, res_data: dict[str, Any]) -> None:
574577
"""

tests/collector/helpers/test_collector_runtime.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ def test_collect_runtime_snapshot_default(self) -> None:
3838
self.helper._collect_runtime_snapshot(plugin_data[0])
3939
assert plugin_data[0]["name"] == "com.instana.plugin.python"
4040
assert plugin_data[0]["data"]["snapshot"]["m"] == "Manual"
41-
assert len(plugin_data[0]["data"]) == 3
41+
# data contains: pid, metrics, pollRate, snapshot
42+
assert len(plugin_data[0]["data"]) == 4
4243

4344
def test_collect_runtime_snapshot_autowrapt(self) -> None:
4445
with patch(
@@ -49,7 +50,8 @@ def test_collect_runtime_snapshot_autowrapt(self) -> None:
4950
self.helper._collect_runtime_snapshot(plugin_data[0])
5051
assert plugin_data[0]["name"] == "com.instana.plugin.python"
5152
assert plugin_data[0]["data"]["snapshot"]["m"] == "Autowrapt"
52-
assert len(plugin_data[0]["data"]) == 3
53+
# data contains: pid, metrics, pollRate, snapshot
54+
assert len(plugin_data[0]["data"]) == 4
5355

5456
def test_collect_runtime_snapshot_webhook(self) -> None:
5557
with patch(
@@ -60,7 +62,8 @@ def test_collect_runtime_snapshot_webhook(self) -> None:
6062
self.helper._collect_runtime_snapshot(plugin_data[0])
6163
assert plugin_data[0]["name"] == "com.instana.plugin.python"
6264
assert plugin_data[0]["data"]["snapshot"]["m"] == "AutoTrace"
63-
assert len(plugin_data[0]["data"]) == 3
65+
# data contains: pid, metrics, pollRate, snapshot
66+
assert len(plugin_data[0]["data"]) == 4
6467

6568
def test_collect_gc_metrics(self) -> None:
6669
plugin_data = self.helper.collect_metrics()
@@ -174,6 +177,44 @@ def test_collect_runtime_metrics_with_resource_usage(self, mocker):
174177
# Verify the previous_rusage was updated
175178
assert self.helper.previous_rusage == new_resource
176179

180+
def test_collect_runtime_snapshot_poll_rate_default(self) -> None:
181+
"""pollRate defaults to 1 when agent has no options configured."""
182+
plugin_data = self.helper.collect_metrics()
183+
self.helper._collect_runtime_snapshot(plugin_data[0])
184+
assert plugin_data[0]["data"]["pollRate"] == 1
185+
186+
def test_collect_runtime_snapshot_poll_rate_from_agent_options(self) -> None:
187+
"""pollRate is read from agent.options.poll_rate and written to data top-level."""
188+
self.helper.collector.agent.options.poll_rate = 60
189+
plugin_data = self.helper.collect_metrics()
190+
self.helper._collect_runtime_snapshot(plugin_data[0])
191+
assert plugin_data[0]["data"]["pollRate"] == 60
192+
193+
def test_collect_runtime_snapshot_poll_rate_no_options(self) -> None:
194+
"""pollRate defaults to 1 when agent has no options attribute."""
195+
self.helper.collector.agent.options = None
196+
plugin_data = self.helper.collect_metrics()
197+
self.helper._collect_runtime_snapshot(plugin_data[0])
198+
assert plugin_data[0]["data"]["pollRate"] == 1
199+
200+
def test_collect_runtime_snapshot_poll_rate_no_agent(self) -> None:
201+
"""pollRate defaults to 1 when collector has no agent attribute."""
202+
self.helper.collector.agent = None
203+
plugin_data = self.helper.collect_metrics()
204+
self.helper._collect_runtime_snapshot(plugin_data[0])
205+
assert plugin_data[0]["data"]["pollRate"] == 1
206+
207+
def test_collect_runtime_snapshot_poll_rate_at_top_level_not_in_snapshot(self) -> None:
208+
"""pollRate must be at data top-level, not nested inside snapshot.
209+
Backend's PollRateUtil.regularPollRateFromPayload() reads payload.getByPath("pollRate")
210+
which is a flat lookup on the data map — it cannot find a nested key.
211+
"""
212+
self.helper.collector.agent.options.poll_rate = 30
213+
plugin_data = self.helper.collect_metrics()
214+
self.helper._collect_runtime_snapshot(plugin_data[0])
215+
assert plugin_data[0]["data"]["pollRate"] == 30
216+
assert "pollRate" not in plugin_data[0]["data"].get("snapshot", {})
217+
177218
@patch("os.environ")
178219
def test_collect_runtime_metrics_disabled(self, mock_environ):
179220
"""Test that _collect_runtime_metrics respects INSTANA_DISABLE_METRICS_COLLECTION"""

tests/test_options.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import logging
44
import os
5-
from typing import Generator, Optional
5+
from collections.abc import Generator
6+
from typing import Optional
67

78
import pytest
89
from mock import patch
@@ -1093,14 +1094,14 @@ def test_default_poll_rate(self) -> None:
10931094

10941095
@pytest.mark.parametrize(
10951096
"poll_rate_value",
1096-
[1, 5],
1097+
[1, 5, 10, 60, 600],
10971098
)
10981099
def test_set_from_with_valid_poll_rate(
10991100
self,
11001101
poll_rate_value: int,
11011102
caplog: pytest.LogCaptureFixture,
11021103
) -> None:
1103-
"""Test setting poll_rate from announce response - affects metrics only"""
1104+
"""Test setting poll_rate from announce response — exact valid values are accepted as-is."""
11041105
caplog.set_level(logging.DEBUG, logger="instana")
11051106
caplog.clear()
11061107

@@ -1115,27 +1116,60 @@ def test_set_from_with_valid_poll_rate(
11151116
)
11161117

11171118
@pytest.mark.parametrize(
1118-
"invalid_value",
1119-
[10, 0, -5, 3],
1119+
"invalid_value,expected",
1120+
[
1121+
(0, 1), # zero → default
1122+
(-5, 1), # negative → default
1123+
],
11201124
)
11211125
def test_set_from_with_invalid_poll_rate_defaults_to_1(
11221126
self,
11231127
invalid_value: int,
1128+
expected: int,
11241129
caplog: pytest.LogCaptureFixture,
11251130
) -> None:
1126-
"""Test that invalid poll_rate values default to 1"""
1131+
"""Test that zero and negative poll_rate values default to 1."""
11271132
caplog.set_level(logging.DEBUG, logger="instana")
11281133
caplog.clear()
11291134

11301135
self.standart_options = StandardOptions()
11311136
test_res_data = {"plugin": {"python": {"poll_rate": invalid_value}}}
11321137
self.standart_options.set_from(test_res_data)
1133-
assert self.standart_options.poll_rate == 1
1138+
assert self.standart_options.poll_rate == expected
11341139
assert (
11351140
f"Invalid poll_rate value {invalid_value}, defaulting to 1"
11361141
in caplog.messages
11371142
)
11381143

1144+
@pytest.mark.parametrize(
1145+
"input_value,expected",
1146+
[
1147+
(3, 1), # nearest to 1
1148+
(7, 5), # nearest to 5 (|7-5|=2 < |7-10|=3)
1149+
(8, 10), # nearest to 10
1150+
(100, 120), # nearest to 120 (|100-60|=40 > |100-120|=20)
1151+
(700, 600), # above max → clamp to 600
1152+
],
1153+
)
1154+
def test_set_from_with_non_exact_poll_rate_rounds_to_nearest(
1155+
self,
1156+
input_value: int,
1157+
expected: int,
1158+
caplog: pytest.LogCaptureFixture,
1159+
) -> None:
1160+
"""Test that non-exact values are rounded to the nearest valid poll rate."""
1161+
caplog.set_level(logging.DEBUG, logger="instana")
1162+
caplog.clear()
1163+
1164+
self.standart_options = StandardOptions()
1165+
test_res_data = {"plugin": {"python": {"poll_rate": input_value}}}
1166+
self.standart_options.set_from(test_res_data)
1167+
assert self.standart_options.poll_rate == expected
1168+
assert (
1169+
f"Poll rate set to {expected} seconds from agent configuration"
1170+
in caplog.messages
1171+
)
1172+
11391173
@pytest.mark.parametrize(
11401174
"invalid_type,expect_log",
11411175
[

0 commit comments

Comments
 (0)