From df574aa71ecdc314d34cf9b3159a2d233b0e99ed Mon Sep 17 00:00:00 2001 From: Cagri Yonca Date: Tue, 11 Aug 2026 17:14:39 +0200 Subject: [PATCH 1/2] feat(runtime): Update pollrate behavior and add tests for it Signed-off-by: Cagri Yonca --- src/instana/options.py | 19 ++++++++++------- tests/test_options.py | 48 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/instana/options.py b/src/instana/options.py index 5d4c3df8..f3b2023d 100644 --- a/src/instana/options.py +++ b/src/instana/options.py @@ -360,7 +360,7 @@ class StandardOptions(BaseOptions): AGENT_DEFAULT_HOST = "localhost" AGENT_DEFAULT_PORT = 42699 DEFAULT_POLL_RATE = 1 - MAX_POLL_RATE = 5 + VALID_POLL_RATES = [1, 5, 10, 20, 30, 60, 120, 180, 240, 300, 360, 420, 480, 540, 600] def __init__(self, **kwds: dict[str, Any]) -> None: super(StandardOptions, self).__init__() @@ -543,7 +543,11 @@ def set_disable_tracing(self, tracing_config: Sequence[dict[str, Any]]) -> None: self.enabled_spans.extend(enabled_spans) def set_poll_rate(self, plugin_config: dict[str, Any]) -> None: - """Set poll rate from agent plugin configuration.""" + """Set poll rate from agent plugin configuration. + + Normalizes the received value to the nearest valid poll rate in + VALID_POLL_RATES, matching the behaviour of Java PollRateUtil. + """ poll_rate_value = plugin_config.get("poll_rate") if poll_rate_value is None: return @@ -557,18 +561,17 @@ def set_poll_rate(self, plugin_config: dict[str, Any]) -> None: self.poll_rate = self.DEFAULT_POLL_RATE return - if poll_rate in (self.DEFAULT_POLL_RATE, self.MAX_POLL_RATE): - self.poll_rate = poll_rate + if poll_rate <= 0: + self.poll_rate = self.DEFAULT_POLL_RATE logger.debug( - f"Poll rate set to {self.poll_rate} seconds from agent configuration" + f"Invalid poll_rate value {poll_rate}, defaulting to {self.DEFAULT_POLL_RATE}" ) return + self.poll_rate = min(self.VALID_POLL_RATES, key=lambda x: abs(x - poll_rate)) logger.debug( - f"Invalid poll_rate value {poll_rate}, defaulting to " - f"{self.DEFAULT_POLL_RATE}" + f"Poll rate set to {self.poll_rate} seconds from agent configuration" ) - self.poll_rate = self.DEFAULT_POLL_RATE def set_from(self, res_data: dict[str, Any]) -> None: """ diff --git a/tests/test_options.py b/tests/test_options.py index 02be426a..4c167b8e 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2,7 +2,8 @@ import logging import os -from typing import Generator, Optional +from collections.abc import Generator +from typing import Optional import pytest from mock import patch @@ -1093,14 +1094,14 @@ def test_default_poll_rate(self) -> None: @pytest.mark.parametrize( "poll_rate_value", - [1, 5], + [1, 5, 10, 60, 600], ) def test_set_from_with_valid_poll_rate( self, poll_rate_value: int, caplog: pytest.LogCaptureFixture, ) -> None: - """Test setting poll_rate from announce response - affects metrics only""" + """Test setting poll_rate from announce response — exact valid values are accepted as-is.""" caplog.set_level(logging.DEBUG, logger="instana") caplog.clear() @@ -1115,27 +1116,60 @@ def test_set_from_with_valid_poll_rate( ) @pytest.mark.parametrize( - "invalid_value", - [10, 0, -5, 3], + "invalid_value,expected", + [ + (0, 1), # zero → default + (-5, 1), # negative → default + ], ) def test_set_from_with_invalid_poll_rate_defaults_to_1( self, invalid_value: int, + expected: int, caplog: pytest.LogCaptureFixture, ) -> None: - """Test that invalid poll_rate values default to 1""" + """Test that zero and negative poll_rate values default to 1.""" caplog.set_level(logging.DEBUG, logger="instana") caplog.clear() self.standart_options = StandardOptions() test_res_data = {"plugin": {"python": {"poll_rate": invalid_value}}} self.standart_options.set_from(test_res_data) - assert self.standart_options.poll_rate == 1 + assert self.standart_options.poll_rate == expected assert ( f"Invalid poll_rate value {invalid_value}, defaulting to 1" in caplog.messages ) + @pytest.mark.parametrize( + "input_value,expected", + [ + (3, 1), # nearest to 1 + (7, 5), # nearest to 5 (|7-5|=2 < |7-10|=3) + (8, 10), # nearest to 10 + (100, 120), # nearest to 120 (|100-60|=40 > |100-120|=20) + (700, 600), # above max → clamp to 600 + ], + ) + def test_set_from_with_non_exact_poll_rate_rounds_to_nearest( + self, + input_value: int, + expected: int, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Test that non-exact values are rounded to the nearest valid poll rate.""" + caplog.set_level(logging.DEBUG, logger="instana") + caplog.clear() + + self.standart_options = StandardOptions() + test_res_data = {"plugin": {"python": {"poll_rate": input_value}}} + self.standart_options.set_from(test_res_data) + assert self.standart_options.poll_rate == expected + assert ( + f"Poll rate set to {expected} seconds from agent configuration" + in caplog.messages + ) + @pytest.mark.parametrize( "invalid_type,expect_log", [ From 07c716ccfb0f82f756afc265cb823716b7abff61 Mon Sep 17 00:00:00 2001 From: Cagri Yonca Date: Tue, 11 Aug 2026 17:14:53 +0200 Subject: [PATCH 2/2] feat(system-metrics): Change gc.get_count with gc.get_stats with corresponding metrics. Signed-off-by: Cagri Yonca --- src/instana/agent/host.py | 62 ++++- src/instana/collector/helpers/runtime.py | 77 ++---- tests/agent/test_host.py | 4 +- .../helpers/test_collector_runtime.py | 69 ++++- tests/collector/test_host_collector.py | 247 +++++++++++++++--- 5 files changed, 361 insertions(+), 98 deletions(-) diff --git a/src/instana/agent/host.py b/src/instana/agent/host.py index 1bc7a3fc..3f317471 100644 --- a/src/instana/agent/host.py +++ b/src/instana/agent/host.py @@ -99,13 +99,17 @@ def reset(self) -> None: def is_timed_out(self) -> bool: """ - If we haven't heard from the Instana host agent in 60 seconds, this - method will return True. + If we haven't heard from the Instana host agent within the timeout + window, this method will return True. The window is the larger of + 60 seconds or twice the configured poll_rate so that high poll_rate + values (e.g. 120 s) do not cause spurious resets. @return: Boolean """ - if self.last_seen and self.can_send: + if self.last_seen and self.can_send(): + poll_rate = getattr(getattr(self, "options", None), "poll_rate", 1) + timeout_threshold = max(60, poll_rate * 2) diff = datetime.now() - self.last_seen - if diff.seconds > 60: + if diff.seconds > timeout_threshold: return True return False @@ -276,31 +280,40 @@ def report_data_payload( ) -> Optional[Response]: """ Used to report collection payload to the host agent. This can be metrics, spans and snapshot data. + When there is nothing to send (no spans, profiles, or metrics), a lightweight HEAD heartbeat + is sent instead so that the host-agent timeout detection continues to work correctly even + when poll_rate is larger than the 60-second timeout window. """ response = None + data_was_sent = False try: # Report spans (if any) response = self.report_spans(payload) - if response is not None and 200 <= response.status_code <= 204: self.last_seen = datetime.now() + data_was_sent = True # Report profiles (if any) response = self.report_profiles(payload) - if response is not None and 200 <= response.status_code <= 204: self.last_seen = datetime.now() + data_was_sent = True # Report metrics response = self.report_metrics(payload) - if response is not None and 200 <= response.status_code <= 204: self.last_seen = datetime.now() + data_was_sent = True if response.status_code == 200 and len(response.content) > 2: - # The host agent returned something indicating that is has a request for us that we - # need to process. + # The host agent returned something indicating that it has a request for us + # that we need to process. self.handle_agent_tasks(json.loads(response.content)[0]) + + # Nothing was sent this cycle — send a heartbeat HEAD request so that + # is_timed_out() keeps working correctly at high poll_rate values. + if not data_was_sent: + self._send_heartbeat() except requests.exceptions.ConnectionError: pass except urllib3.exceptions.MaxRetryError: @@ -312,6 +325,37 @@ def report_data_payload( ) return response + def _send_heartbeat(self) -> None: + """ + Sends a lightweight HEAD request to the host agent data endpoint to confirm + connectivity and update last_seen when no metrics, spans or profiles were sent. + + Guards: + - Only runs in "good2go" state — during wait4init the FSM polling already + performs HEAD checks via is_agent_ready(), so a second HEAD is unnecessary. + - announce_data must be set (skipped silently during pre-announce). + - A local copy of announce_data is taken before the request to avoid a race + condition where reset() sets announce_data=None mid-flight. + """ + try: + announce_data = self.announce_data # local copy — avoids race with reset() + if announce_data is None: + return + if self.machine.fsm.current != "good2go": + return + response = self.client.head(self.__data_url(), timeout=0.8) + if response is not None and 200 <= response.status_code <= 204: + self.last_seen = datetime.now() + except requests.exceptions.ConnectionError: + pass + except urllib3.exceptions.MaxRetryError: + pass + except Exception as exc: + logger.debug( + f"_send_heartbeat: connection error ({type(exc)})", + exc_info=True, + ) + def report_metrics(self, payload: dict[str, Any]) -> Optional[Response]: metrics = payload.get("metrics", []) if len(metrics) > 0 and len(metrics.get("plugins", [])) > 0: diff --git a/src/instana/collector/helpers/runtime.py b/src/instana/collector/helpers/runtime.py index 8156ffd9..ed511256 100644 --- a/src/instana/collector/helpers/runtime.py +++ b/src/instana/collector/helpers/runtime.py @@ -44,10 +44,11 @@ def __init__( self.previous = DictionaryOfStan() self.previous_rusage = get_resource_usage() - if gc.isenabled(): - self.previous_gc_count = gc.get_count() - else: - self.previous_gc_count = None + # Initialise to None so the first collect_metrics call establishes the + # baseline snapshot and reports all-zero deltas. Any GC activity that + # occurs between process start and the first collection is intentionally + # excluded — we only report incremental deltas from the first poll onward. + self.previous_gc_stats = None def collect_metrics(self, **kwargs: Dict[str, Any]) -> List[Dict[str, Any]]: plugin_data = dict() @@ -82,6 +83,7 @@ def _collect_runtime_metrics( return """ Collect up and return the runtime metrics """ + rusage = self.previous_rusage try: rusage = get_resource_usage() if gc.isenabled(): @@ -232,52 +234,27 @@ def _collect_runtime_metrics( def _collect_gc_metrics(self, plugin_data, with_snapshot): try: - gc_count = gc.get_count() - gc_threshold = gc.get_threshold() - - self.apply_delta( - gc_count[0], - self.previous["data"]["metrics"]["gc"], - plugin_data["data"]["metrics"]["gc"], - "collect0", - with_snapshot, - ) - self.apply_delta( - gc_count[1], - self.previous["data"]["metrics"]["gc"], - plugin_data["data"]["metrics"]["gc"], - "collect1", - with_snapshot, - ) - self.apply_delta( - gc_count[2], - self.previous["data"]["metrics"]["gc"], - plugin_data["data"]["metrics"]["gc"], - "collect2", - with_snapshot, - ) - - self.apply_delta( - gc_threshold[0], - self.previous["data"]["metrics"]["gc"], - plugin_data["data"]["metrics"]["gc"], - "threshold0", - with_snapshot, - ) - self.apply_delta( - gc_threshold[1], - self.previous["data"]["metrics"]["gc"], - plugin_data["data"]["metrics"]["gc"], - "threshold1", - with_snapshot, - ) - self.apply_delta( - gc_threshold[2], - self.previous["data"]["metrics"]["gc"], - plugin_data["data"]["metrics"]["gc"], - "threshold2", - with_snapshot, - ) + gc_stats = gc.get_stats() + if self.previous_gc_stats is None: + # First call: establish baseline, report all-zero deltas so the + # snapshot payload carries zeros rather than the cumulative counts + # that accumulated since process start (which are not meaningful + # as deltas). + self.previous_gc_stats = gc_stats + return + + # Use a plain dict as the staging target so that accessing it never + # auto-creates keys in plugin_data (DictionaryOfStan creates keys on + # read, which would leave an empty "gc": {} even when nothing changed). + staging: dict = {} + prev_gc = self.previous["data"]["metrics"]["gc"] + for i, (stat, prev_stat) in enumerate(zip(gc_stats, self.previous_gc_stats)): + for key in ("collections", "collected", "uncollectable"): + delta = stat[key] - prev_stat.get(key, 0) + self.apply_delta(delta, prev_gc, staging, f"{key}{i}", with_snapshot) + if staging: + plugin_data["data"]["metrics"]["gc"].update(staging) + self.previous_gc_stats = gc_stats except Exception: logger.debug("_collect_gc_metrics", exc_info=True) diff --git a/tests/agent/test_host.py b/tests/agent/test_host.py index 0aadbe66..9f418968 100644 --- a/tests/agent/test_host.py +++ b/tests/agent/test_host.py @@ -334,8 +334,8 @@ def test_is_timed_out( assert not agent.is_timed_out() agent.last_seen = datetime.datetime.now() - datetime.timedelta(minutes=5) - agent.can_send = True - assert agent.is_timed_out() + with patch.object(agent, "can_send", return_value=True): + assert agent.is_timed_out() def test_can_send_test_env( self, diff --git a/tests/collector/helpers/test_collector_runtime.py b/tests/collector/helpers/test_collector_runtime.py index 959f7611..27617bbb 100644 --- a/tests/collector/helpers/test_collector_runtime.py +++ b/tests/collector/helpers/test_collector_runtime.py @@ -27,7 +27,7 @@ def test_default_while_gc_disabled(self) -> None: gc.disable() helper = RuntimeHelper(collector=HostCollector(HostAgent())) - assert helper.previous_gc_count is None + assert helper.previous_gc_stats is None def test_collect_metrics(self) -> None: response = self.helper.collect_metrics() @@ -65,8 +65,73 @@ def test_collect_runtime_snapshot_webhook(self) -> None: def test_collect_gc_metrics(self) -> None: plugin_data = self.helper.collect_metrics() + # First call establishes the baseline (previous_gc_stats was None); no + # data is written yet. self.helper._collect_gc_metrics(plugin_data[0], True) - assert len(self.helper.previous["data"]["metrics"]["gc"]) == 6 + assert self.helper.previous_gc_stats is not None + + # Second call computes deltas from the baseline and writes them. + self.helper._collect_gc_metrics(plugin_data[0], True) + gc_metrics = self.helper.previous["data"]["metrics"]["gc"] + for i in range(3): + for key in ("collections", "collected", "uncollectable"): + assert f"{key}{i}" in gc_metrics + + def test_collect_gc_metrics_reports_delta_between_polls(self) -> None: + """GC metrics must be deltas between successive polls, not kumulatif values.""" + # Simulate first poll: previous_gc_stats set to known baseline + baseline = [ + {"collections": 100, "collected": 200, "uncollectable": 0}, + {"collections": 10, "collected": 50, "uncollectable": 0}, + {"collections": 1, "collected": 5, "uncollectable": 0}, + ] + self.helper.previous_gc_stats = baseline + + # Simulate gc.get_stats() returning incremented counts + after = [ + {"collections": 103, "collected": 206, "uncollectable": 0}, + {"collections": 11, "collected": 53, "uncollectable": 0}, + {"collections": 1, "collected": 5, "uncollectable": 0}, + ] + + plugin_data = [{"data": {"metrics": {"gc": {}}}}] + + import unittest.mock as mock + with mock.patch("gc.get_stats", return_value=after): + self.helper._collect_gc_metrics(plugin_data[0], True) + + gc_metrics = plugin_data[0]["data"]["metrics"]["gc"] + # Gen 0: collections delta = 3, collected delta = 6 + assert gc_metrics["collections0"] == 3 + assert gc_metrics["collected0"] == 6 + assert gc_metrics["uncollectable0"] == 0 + # Gen 1: collections delta = 1, collected delta = 3 + assert gc_metrics["collections1"] == 1 + assert gc_metrics["collected1"] == 3 + # Gen 2: no change — delta = 0, still reported because with_snapshot=True + assert gc_metrics["collections2"] == 0 + assert gc_metrics["collected2"] == 0 + + # previous_gc_stats must be updated to the latest snapshot + assert self.helper.previous_gc_stats == after + + def test_collect_gc_metrics_no_change_not_sent_without_snapshot(self) -> None: + """When nothing changed and with_snapshot=False, gc metrics must be empty.""" + same = [ + {"collections": 50, "collected": 100, "uncollectable": 0}, + {"collections": 5, "collected": 20, "uncollectable": 0}, + {"collections": 0, "collected": 0, "uncollectable": 0}, + ] + self.helper.previous_gc_stats = same + + plugin_data = [{"data": {"metrics": {"gc": {}}}}] + + import unittest.mock as mock + with mock.patch("gc.get_stats", return_value=same): + self.helper._collect_gc_metrics(plugin_data[0], False) + + # All deltas are 0 and with_snapshot=False → nothing written + assert plugin_data[0]["data"]["metrics"]["gc"] == {} def test_collect_runtime_metrics(self) -> None: """Test that _collect_runtime_metrics properly collects metrics""" diff --git a/tests/collector/test_host_collector.py b/tests/collector/test_host_collector.py index 1d950328..25c7f2e4 100644 --- a/tests/collector/test_host_collector.py +++ b/tests/collector/test_host_collector.py @@ -6,17 +6,18 @@ import os import sys import threading -from typing import Generator +from collections.abc import Generator import pytest +from mock import patch +from pytest import LogCaptureFixture + from instana.collector.helpers.runtime import ( PATH_OF_AUTOTRACE_WEBHOOK_SITEDIR, ) from instana.collector.host import HostCollector from instana.singletons import get_agent, get_tracer from instana.version import VERSION -from mock import patch -from pytest import LogCaptureFixture class TestHostCollector: @@ -101,6 +102,7 @@ def test_should_send_metrics_with_default_poll_rate(self) -> None: def test_should_send_metrics_with_custom_poll_rate(self) -> None: """Test that metrics respect custom poll_rate from agent options""" from time import time + from instana.options import StandardOptions # Set custom poll_rate of 5 seconds @@ -146,6 +148,7 @@ def test_should_send_metrics_without_agent_options(self) -> None: def test_prepare_payload_respects_poll_rate(self) -> None: """Test that prepare_payload only collects metrics based on poll_rate""" from time import time + from instana.options import StandardOptions # Set poll_rate to 5 seconds @@ -179,6 +182,7 @@ def test_prepare_payload_respects_poll_rate(self) -> None: def test_metrics_data_last_sent_updated(self) -> None: """Test that metrics_data_last_sent timestamp is updated after collecting metrics""" from time import time + from instana.options import StandardOptions self.agent.options = StandardOptions() @@ -200,10 +204,10 @@ def test_metrics_data_last_sent_updated(self) -> None: def test_prepare_payload_spans_always_collected(self) -> None: """Test that spans are always collected regardless of poll_rate""" from instana.options import StandardOptions - from instana.span.span import InstanaSpan + from instana.recorder import StanRecorder from instana.span.registered_span import RegisteredSpan + from instana.span.span import InstanaSpan from instana.span_context import SpanContext - from instana.recorder import StanRecorder # Set high poll_rate self.agent.options = StandardOptions() @@ -239,6 +243,7 @@ def test_prepare_payload_spans_always_collected(self) -> None: def test_prepare_payload_basics(self) -> None: with patch.object(gc, "isenabled", return_value=True): + # First call establishes the GC baseline; no gc metrics in payload yet. self.payload = self.agent.collector.prepare_payload() assert self.payload @@ -309,38 +314,22 @@ def test_prepare_payload_basics(self) -> None: int, ] + # Second call: GC baseline is now set, so deltas are reported. + # Reset both timestamps so metrics AND snapshot are collected. + self.agent.collector.metrics_data_last_sent = 0 + self.agent.collector.snapshot_data_last_sent = 0 + self.payload = self.agent.collector.prepare_payload() + python_plugin = self.payload["metrics"]["plugins"][0] assert "gc" in python_plugin["data"]["metrics"] assert isinstance(python_plugin["data"]["metrics"]["gc"], dict) - assert "collect0" in python_plugin["data"]["metrics"]["gc"] - assert type(python_plugin["data"]["metrics"]["gc"]["collect0"]) in [ - float, - int, - ] - assert "collect1" in python_plugin["data"]["metrics"]["gc"] - assert type(python_plugin["data"]["metrics"]["gc"]["collect1"]) in [ - float, - int, - ] - assert "collect2" in python_plugin["data"]["metrics"]["gc"] - assert type(python_plugin["data"]["metrics"]["gc"]["collect2"]) in [ - float, - int, - ] - assert "threshold0" in python_plugin["data"]["metrics"]["gc"] - assert type(python_plugin["data"]["metrics"]["gc"]["threshold0"]) in [ - float, - int, - ] - assert "threshold1" in python_plugin["data"]["metrics"]["gc"] - assert type(python_plugin["data"]["metrics"]["gc"]["threshold1"]) in [ - float, - int, - ] - assert "threshold2" in python_plugin["data"]["metrics"]["gc"] - assert type(python_plugin["data"]["metrics"]["gc"]["threshold2"]) in [ - float, - int, - ] + for i in range(3): + for key in ("collections", "collected", "uncollectable"): + metric_key = f"{key}{i}" + assert metric_key in python_plugin["data"]["metrics"]["gc"] + assert type(python_plugin["data"]["metrics"]["gc"][metric_key]) in [ + float, + int, + ] def test_prepare_payload_basics_disable_runtime_metrics(self) -> None: os.environ["INSTANA_DISABLE_METRICS_COLLECTION"] = "TRUE" @@ -521,3 +510,191 @@ def mock_is_agent_ready(): self.agent.collector.prepare_and_report_data() # The second lock acquisition should see the new state assert self.agent.collector.agent.machine.fsm.current == "good2go" + + +class TestHostAgentHeartbeat: + """Tests for _send_heartbeat() and related poll_rate-aware timeout logic.""" + + @pytest.fixture(autouse=True) + def _resource(self) -> Generator[None, None, None]: + from instana.collector.host import HostCollector + from instana.singletons import get_agent + + self.agent = get_agent() + self.agent.collector = HostCollector(self.agent) + yield + self.agent.collector.shutdown(report_final=False) + + # ------------------------------------------------------------------ + # _send_heartbeat + # ------------------------------------------------------------------ + + def test_heartbeat_updates_last_seen_on_success(self) -> None: + """HEAD 200 → last_seen must be updated.""" + from unittest.mock import MagicMock + + from instana.agent.host import AnnounceData + + self.agent.announce_data = AnnounceData(pid=12345, agent_uuid="uuid-1") + + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch.object(self.agent.client, "head", return_value=mock_response): + self.agent._send_heartbeat() + + assert self.agent.last_seen is not None + + def test_heartbeat_skipped_when_announce_data_is_none(self) -> None: + """announce_data=None (pre-announce) → HEAD must NOT be called.""" + self.agent.announce_data = None + self.agent.last_seen = None + + with patch.object(self.agent.client, "head") as mock_head: + self.agent._send_heartbeat() + mock_head.assert_not_called() + + assert self.agent.last_seen is None + + def test_heartbeat_skipped_when_not_in_good2go_state(self) -> None: + """FSM state != good2go (e.g. wait4init) → HEAD must NOT be called.""" + from instana.agent.host import AnnounceData + + self.agent.announce_data = AnnounceData(pid=12345, agent_uuid="uuid-1") + self.agent.last_seen = None + self.agent.machine.fsm.current = "wait4init" + + with patch.object(self.agent.client, "head") as mock_head: + self.agent._send_heartbeat() + mock_head.assert_not_called() + + assert self.agent.last_seen is None + + def test_heartbeat_does_not_update_last_seen_on_failure(self) -> None: + """HEAD 500 → last_seen must NOT be updated.""" + from unittest.mock import MagicMock + + from instana.agent.host import AnnounceData + + self.agent.announce_data = AnnounceData(pid=12345, agent_uuid="uuid-1") + self.agent.last_seen = None + + mock_response = MagicMock() + mock_response.status_code = 500 + + with patch.object(self.agent.client, "head", return_value=mock_response): + self.agent._send_heartbeat() + + assert self.agent.last_seen is None + + def test_heartbeat_handles_connection_error_silently(self) -> None: + """ConnectionError → no exception propagated, last_seen unchanged.""" + import requests + + from instana.agent.host import AnnounceData + + self.agent.announce_data = AnnounceData(pid=12345, agent_uuid="uuid-1") + self.agent.last_seen = None + + with patch.object( + self.agent.client, "head", + side_effect=requests.exceptions.ConnectionError + ): + self.agent._send_heartbeat() # must not raise + + assert self.agent.last_seen is None + + # ------------------------------------------------------------------ + # report_data_payload → heartbeat integration + # ------------------------------------------------------------------ + + def test_heartbeat_called_when_no_data_sent(self) -> None: + """Empty payload (no spans/profiles/metrics) → _send_heartbeat is called.""" + from instana.util import DictionaryOfStan + + empty_payload = DictionaryOfStan() + empty_payload["spans"] = [] + empty_payload["profiles"] = [] + empty_payload["metrics"]["plugins"] = [] + + with patch.object(self.agent, "_send_heartbeat") as mock_hb: + self.agent.report_data_payload(empty_payload) + mock_hb.assert_called_once() + + def test_heartbeat_not_called_when_metrics_sent(self) -> None: + """Metrics present and successfully sent → _send_heartbeat must NOT be called.""" + from unittest.mock import MagicMock + + from instana.agent.host import AnnounceData + from instana.util import DictionaryOfStan + + self.agent.announce_data = AnnounceData(pid=12345, agent_uuid="uuid-1") + + payload = DictionaryOfStan() + payload["spans"] = [] + payload["profiles"] = [] + payload["metrics"]["plugins"] = [{"data": {"ru_utime": 0.1}}] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"{}" + + with patch.object(self.agent.client, "post", return_value=mock_response), \ + patch.object(self.agent, "_send_heartbeat") as mock_hb: + self.agent.report_data_payload(payload) + mock_hb.assert_not_called() + + # ------------------------------------------------------------------ + # is_timed_out — poll_rate-aware threshold + # ------------------------------------------------------------------ + + def test_is_timed_out_default_threshold_60s(self) -> None: + """Default poll_rate=1 → timeout threshold is 60 s.""" + from datetime import datetime, timedelta + + from instana.options import StandardOptions + + self.agent.options = StandardOptions() + self.agent.options.poll_rate = 1 + self.agent.last_seen = datetime.now() - timedelta(seconds=61) + + with patch.object(self.agent, "can_send", return_value=True): + assert self.agent.is_timed_out() + + def test_is_timed_out_not_triggered_before_threshold(self) -> None: + """last_seen 59 s ago with poll_rate=1 → not timed out.""" + from datetime import datetime, timedelta + + from instana.options import StandardOptions + + self.agent.options = StandardOptions() + self.agent.options.poll_rate = 1 + self.agent.last_seen = datetime.now() - timedelta(seconds=59) + + with patch.object(self.agent, "can_send", return_value=True): + assert not self.agent.is_timed_out() + + def test_is_timed_out_uses_poll_rate_times_two_when_larger(self) -> None: + """poll_rate=120 → threshold becomes 240 s, not 60 s.""" + from datetime import datetime, timedelta + + from instana.options import StandardOptions + + self.agent.options = StandardOptions() + self.agent.options.poll_rate = 120 + # 61 s ago — would fire with old 60 s threshold, must NOT fire now + self.agent.last_seen = datetime.now() - timedelta(seconds=61) + + with patch.object(self.agent, "can_send", return_value=True): + assert not self.agent.is_timed_out() + + # 241 s ago — exceeds poll_rate*2=240, must fire + self.agent.last_seen = datetime.now() - timedelta(seconds=241) + with patch.object(self.agent, "can_send", return_value=True): + assert self.agent.is_timed_out() + + def test_is_timed_out_false_when_last_seen_is_none(self) -> None: + """last_seen=None (never connected) → not timed out.""" + self.agent.last_seen = None + with patch.object(self.agent, "can_send", return_value=True): + assert not self.agent.is_timed_out()