Skip to content

Commit 5cc6fb0

Browse files
pawelchckimabdinurcursoragent
authored
[codex] test(telemetry): deduplicate fork-cloned heartbeats (#7304)
Co-authored-by: Munir Abdinur <munir.abdinur@datadoghq.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3a530b2 commit 5cc6fb0

5 files changed

Lines changed: 186 additions & 48 deletions

File tree

manifests/python.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2510,9 +2510,6 @@ manifest:
25102510
tests/test_telemetry.py::Test_Telemetry: v1.16.0
25112511
tests/test_telemetry.py::Test_Telemetry::test_api_still_v1: irrelevant
25122512
tests/test_telemetry.py::Test_Telemetry::test_app_dependencies_loaded: irrelevant
2513-
tests/test_telemetry.py::Test_Telemetry::test_app_heartbeats_delays:
2514-
- weblog_declaration:
2515-
uwsgi-poc: flaky (APMAPI-2167)
25162513
tests/test_telemetry.py::Test_Telemetry::test_app_product_change: missing_feature (Weblog GET/enable_product and app-product-change event is not implemented yet.)
25172514
tests/test_telemetry.py::Test_Telemetry::test_app_started_is_first_message:
25182515
- declaration: flaky (APMAPI-1858)

tests/test_telemetry.py

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from dateutil.parser import isoparse
1111

12+
from tests.test_telemetry_heartbeat_utils import heartbeat_delays_by_runtime
1213
from utils import bug, context, features, interfaces, logger, rfc, scenarios, weblog
1314
from utils.interfaces._misc_validators import HeadersMatchValidator, HeadersPresenceValidator
1415
from utils.telemetry import get_lang_configs, load_telemetry_json
@@ -312,59 +313,25 @@ def save_data(data: dict, container: dict):
312313
raise Exception("The following telemetry messages were not forwarded by the agent")
313314

314315
@staticmethod
315-
def _get_heartbeat_delays_by_runtime() -> dict:
316+
def _get_heartbeat_delays_by_runtime() -> dict[str, list[float]]:
316317
"""Returns a dict where :
317318
The key is the runtime id
318319
The value is a list of delay observed on this runtime id
319320
"""
320321

321-
telemetry_data = list(interfaces.library.get_telemetry_data())
322-
heartbeats_by_runtime = defaultdict(list)
323-
324-
for data in telemetry_data:
325-
if data["request"]["content"].get("request_type") == "app-heartbeat":
326-
heartbeats_by_runtime[data["request"]["content"]["runtime_id"]].append(data)
327-
328-
delays_by_runtime = {}
329-
330-
# Measure only long-lived application runtimes, not the short-lived children the session-id
331-
# tests spawn via /spawn_child (a forked child can emit under its parent's runtime_id before
332-
# regenerating its own -- a sub-interval "duplicate" that flakes the check). Select by lifespan,
333-
# not heartbeat count, so a slow-drifting worker with fewer heartbeats still gets measured.
334-
def lifespan(heartbeats: list[Any]) -> float:
335-
times = [isoparse(d["request"]["timestamp_start"]) for d in heartbeats]
336-
return (max(times) - min(times)).total_seconds()
337-
338-
heartbeat_counts = {rid: len(hbs) for rid, hbs in heartbeats_by_runtime.items()}
339-
lifespans = {rid: lifespan(hbs) for rid, hbs in heartbeats_by_runtime.items()}
340-
longest = max(lifespans.values(), default=0.0)
341-
measurable_runtimes = {
342-
rid: hbs for rid, hbs in heartbeats_by_runtime.items() if len(hbs) > 2 and lifespans[rid] >= longest * 0.5
343-
}
344-
assert measurable_runtimes, (
322+
delays_by_runtime, heartbeat_counts = heartbeat_delays_by_runtime(
323+
interfaces.library.get_telemetry_data(flatten_message_batches=False),
324+
# Require 2 intervals of lifespan so a short-lived process (e.g. a forked child
325+
# from the session-id tests) can't skew the average with just a couple of
326+
# samples. A normal long-lived runtime clears this easily.
327+
min_lifespan=context.telemetry_heartbeat_interval * 2,
328+
)
329+
assert delays_by_runtime, (
345330
f"No runtime emitted enough heartbeats to check delays (runtimes seen: {heartbeat_counts})"
346331
)
347332

348-
for runtime_id, heartbeats in measurable_runtimes.items():
349-
logger.debug(f"Heartbeats for runtime {runtime_id}:")
350-
351-
# In theory, it's sorted. Let be safe
352-
heartbeats.sort(key=lambda data: isoparse(data["request"]["timestamp_start"]))
353-
354-
prev_message_time = None
355-
delays: list[float] = []
356-
for data in heartbeats:
357-
curr_message_time = isoparse(data["request"]["timestamp_start"])
358-
if prev_message_time is None:
359-
logger.debug(f" * {data['log_filename']}: {curr_message_time}")
360-
else:
361-
delay = (curr_message_time - prev_message_time).total_seconds()
362-
logger.debug(f" * {data['log_filename']}: {curr_message_time} => {delay}s ellapsed")
363-
delays.append(delay)
364-
365-
prev_message_time = curr_message_time
366-
367-
delays_by_runtime[runtime_id] = delays
333+
for runtime_id, delays in delays_by_runtime.items():
334+
logger.debug(f"Heartbeat delays for runtime {runtime_id}: {delays}")
368335

369336
return delays_by_runtime
370337

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import itertools
2+
from collections import defaultdict
3+
from collections.abc import Iterable
4+
from typing import Any
5+
6+
from dateutil.parser import isoparse
7+
8+
9+
def heartbeat_delays_by_runtime(
10+
telemetry_data: Iterable[dict[str, Any]],
11+
*,
12+
min_lifespan: float = 0.0,
13+
) -> tuple[dict[str, list[float]], dict[str, int]]:
14+
"""Return heartbeat delays and heartbeat counts, grouped by runtime ID.
15+
16+
Expects unflattened telemetry data (`get_telemetry_data(flatten_message_batches=False)`):
17+
a flattened message-batch entry inherits the outer batch's seq_id, so heartbeats packed
18+
in the same batch would otherwise collide on (runtime_id, seq_id) alone and get deduped
19+
into one.
20+
21+
`min_lifespan` drops runtimes whose heartbeats span less time than that (e.g. a forked
22+
child that exits after only 2-3 heartbeats). With so few samples, a real (non-duplicate)
23+
heartbeat sent during shutdown can dominate the average delay -- dropping the runtime is
24+
the only way to avoid measuring it. A long-lived runtime with the same anomaly is still
25+
measured, and can still fail.
26+
"""
27+
heartbeats_by_runtime: dict[str, dict[tuple[int, int], dict[str, Any]]] = defaultdict(dict)
28+
29+
for data in telemetry_data:
30+
content: dict[str, Any] = data["request"]["content"]
31+
runtime_id: str = content.get("runtime_id", "")
32+
seq_id: int = content.get("seq_id", 0)
33+
34+
if content.get("request_type") == "message-batch":
35+
entries = list(enumerate(content.get("payload", [])))
36+
else:
37+
entries = [(0, content)]
38+
39+
for batch_index, entry in entries:
40+
if entry.get("request_type") != "app-heartbeat":
41+
continue
42+
43+
# Dedup key (seq_id, batch_index) catches two cases: a real retry (identical
44+
# resend), and a forked child's clone heartbeat. A child briefly shares its
45+
# parent's runtime_id and copies its seq_id counter at fork time, so it can
46+
# independently emit a heartbeat with the same seq_id. Not a guaranteed
47+
# invariant, but the best signal we have to tell "fork clone" apart from
48+
# "genuinely fast heartbeat" without tracking PIDs.
49+
heartbeats_by_runtime[runtime_id].setdefault((seq_id, batch_index), data)
50+
51+
heartbeat_counts = {runtime_id: len(heartbeats) for runtime_id, heartbeats in heartbeats_by_runtime.items()}
52+
delays_by_runtime: dict[str, list[float]] = {}
53+
54+
for runtime_id, heartbeats_by_key in heartbeats_by_runtime.items():
55+
if len(heartbeats_by_key) <= 2:
56+
continue
57+
58+
heartbeats = sorted(
59+
heartbeats_by_key.values(),
60+
key=lambda data: isoparse(data["request"]["timestamp_start"]),
61+
)
62+
times = [isoparse(data["request"]["timestamp_start"]) for data in heartbeats]
63+
lifespan = (times[-1] - times[0]).total_seconds()
64+
if lifespan < min_lifespan:
65+
continue
66+
67+
delays_by_runtime[runtime_id] = [
68+
(current - previous).total_seconds() for previous, current in itertools.pairwise(times)
69+
]
70+
71+
return delays_by_runtime, heartbeat_counts
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from datetime import datetime, timedelta, UTC
2+
from typing import Any
3+
4+
import pytest
5+
6+
from tests.test_telemetry_heartbeat_utils import heartbeat_delays_by_runtime
7+
8+
9+
pytestmark = pytest.mark.scenario("TEST_THE_TEST")
10+
11+
BASE_TIME = datetime(2026, 1, 1, tzinfo=UTC)
12+
13+
14+
def _heartbeat(runtime_id: str, seq_id: int, offset: float) -> dict[str, Any]:
15+
timestamp = BASE_TIME + timedelta(seconds=offset)
16+
return {
17+
"request": {
18+
"timestamp_start": timestamp.isoformat(),
19+
"content": {
20+
"request_type": "app-heartbeat",
21+
"runtime_id": runtime_id,
22+
"seq_id": seq_id,
23+
},
24+
},
25+
"log_filename": f"{runtime_id}-{seq_id}-{offset}.json",
26+
}
27+
28+
29+
def test_fork_duplicate_does_not_affect_parent_cadence() -> None:
30+
messages = [
31+
_heartbeat("parent", 1, 0),
32+
_heartbeat("parent", 2, 2),
33+
_heartbeat("parent", 2, 2.05),
34+
_heartbeat("parent", 3, 4),
35+
_heartbeat("parent", 4, 6),
36+
_heartbeat("child", 1, 2.1),
37+
_heartbeat("child", 2, 4.1),
38+
]
39+
delays, heartbeat_counts = heartbeat_delays_by_runtime(messages)
40+
41+
assert heartbeat_counts == {"parent": 4, "child": 2}
42+
assert set(delays) == {"parent"}
43+
assert delays["parent"] == pytest.approx([2.0, 2.0, 2.0])
44+
45+
46+
def test_distinct_fast_heartbeats_remain_measurable() -> None:
47+
messages = [
48+
_heartbeat("parent", 1, 0),
49+
_heartbeat("parent", 2, 1),
50+
_heartbeat("parent", 3, 2),
51+
_heartbeat("parent", 4, 3),
52+
]
53+
delays, heartbeat_counts = heartbeat_delays_by_runtime(messages)
54+
55+
assert heartbeat_counts == {"parent": 4}
56+
assert delays["parent"] == pytest.approx([1.0, 1.0, 1.0])
57+
58+
59+
def test_short_lived_runtime_excluded_by_min_lifespan() -> None:
60+
"""A short-lived runtime with too few samples shouldn't be measured at all: a shutdown
61+
heartbeat right before exit isn't a duplicate, so dedup can't drop it, and with only
62+
2-3 samples it would skew the average.
63+
"""
64+
messages = [
65+
_heartbeat("parent", 1, 0),
66+
_heartbeat("parent", 2, 2),
67+
_heartbeat("parent", 3, 4),
68+
_heartbeat("parent", 4, 6),
69+
_heartbeat("parent", 5, 8),
70+
# child lives ~2.05s: two normal-ish heartbeats, then an out-of-cadence one
71+
# right before it exits
72+
_heartbeat("child", 1, 0.1),
73+
_heartbeat("child", 2, 2.05),
74+
_heartbeat("child", 3, 2.12),
75+
]
76+
delays, heartbeat_counts = heartbeat_delays_by_runtime(messages, min_lifespan=6.0)
77+
78+
assert heartbeat_counts == {"parent": 5, "child": 3}
79+
assert set(delays) == {"parent"}
80+
81+
82+
def test_long_lived_runtime_anomaly_still_measured() -> None:
83+
"""min_lifespan only excludes runtimes too short-lived to measure. A long-lived runtime
84+
with the same anomalous fast heartbeat is still measured, and can still fail.
85+
"""
86+
messages = [
87+
_heartbeat("parent", 1, 0),
88+
_heartbeat("parent", 2, 2),
89+
_heartbeat("parent", 3, 4),
90+
_heartbeat("parent", 4, 6),
91+
_heartbeat("parent", 5, 8),
92+
_heartbeat("parent", 6, 8.05), # anomalous fast heartbeat, but lifespan is 8.05s
93+
]
94+
delays, heartbeat_counts = heartbeat_delays_by_runtime(messages, min_lifespan=6.0)
95+
96+
assert heartbeat_counts == {"parent": 6}
97+
assert delays["parent"] == pytest.approx([2.0, 2.0, 2.0, 2.0, 0.05])

utils/scripts/libraries_and_scenarios_rules.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,12 @@ patterns:
547547
scenario_groups: null
548548
libraries: null
549549

550+
# This helper has no test items of its own, so it's invisible to the scenario map that
551+
# drives dynamic selection below. List its consumers' scenarios explicitly.
552+
tests/test_telemetry_heartbeat_utils.py:
553+
scenario_groups: null
554+
scenarios: [DEFAULT, TEST_THE_TEST]
555+
550556
tests/fuzzer/*:
551557
scenario_groups: exotics
552558
libraries: null

0 commit comments

Comments
 (0)