Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 57 additions & 9 deletions src/instana/agent/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,24 @@ 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.

Note: We intentionally read the FSM state directly instead of calling
can_send(), because can_send() has a fork-detection side-effect that
triggers handle_fork() → reset() and would cause a spurious restart
loop on frameworks (e.g. Twisted) that spawn threads with different
perceived PIDs. total_seconds() is used instead of .seconds to
correctly handle gaps longer than 24 hours.
@return: Boolean
"""
if self.last_seen and self.can_send:
if self.last_seen and self.machine.fsm.current in ["wait4init", "good2go"]:
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.total_seconds() > timeout_threshold:
return True
return False

Expand Down Expand Up @@ -276,31 +287,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:
Expand All @@ -312,6 +332,34 @@ def report_data_payload(
)
return response

def _send_heartbeat(self) -> None:
"""
Updates last_seen via a HEAD request when no payload was sent this cycle.

Critical when metric collection is disabled (INSTANA_DISABLE_METRICS_COLLECTION)
and no spans arrive — last_seen would never be updated, causing is_timed_out()
to fire spuriously. Only runs in "good2go" state; wait4init already polls via
is_agent_ready().
"""
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:
Expand Down
84 changes: 34 additions & 50 deletions src/instana/collector/helpers/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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 = {}
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)

Expand Down Expand Up @@ -333,6 +310,13 @@ def _collect_runtime_snapshot(
snapshot_payload["versions"] = self.gather_python_packages()
snapshot_payload["iv"] = VERSION

# Inform filler of the configured poll_rate so that entity expiry is
# scaled correctly (presenceExpirySeconds = 20 * poll_rate).
# The backend reads this via SnapshotExtracting.describePollRateSnapshot()
# which looks for the top-level "pollRate" key in the payload.
opts = getattr(getattr(self.collector, "agent", None), "options", None)
plugin_data["data"]["pollRate"] = getattr(opts, "poll_rate", 1) if opts is not None else 1

if is_autowrapt_instrumented():
snapshot_payload["m"] = "Autowrapt"
elif is_webhook_instrumented():
Expand Down
21 changes: 12 additions & 9 deletions src/instana/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand Down Expand Up @@ -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
Expand All @@ -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
logger.debug(
f"Poll rate set to {self.poll_rate} seconds from agent configuration"
if poll_rate <= 0:
self.poll_rate = self.DEFAULT_POLL_RATE
logger.warning(
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:
"""
Expand Down
4 changes: 2 additions & 2 deletions tests/agent/test_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.machine.fsm, "current", "good2go"):
assert agent.is_timed_out()

def test_can_send_test_env(
self,
Expand Down
Loading
Loading