Skip to content

Commit 3a882ea

Browse files
committed
chore(debugger): correct agent endpoint matching
The agent's /info endpoints list may or may not include a leading slash depending on the agent version. The signal uploader only matched the leading-slash form, so newer agents that advertise the debugger endpoints without one were misreported as unsupported: snapshot and log uploads were disabled and an "unsupported agent" warning was logged on every upload cycle (as often as once per second). Normalize the advertised endpoints before matching, consistent with remote config and CI visibility. Also throttle the agent capability check when the agent is reachable but does not advertise the required endpoints, so the warning is not logged every tick. The throttle is scoped to that case only (transient /info failures and upload failures on a supported agent still recover on the next tick) and is reset on fork so a child re-checks immediately.
1 parent 4440d38 commit 3a882ea

3 files changed

Lines changed: 106 additions & 6 deletions

File tree

ddtrace/debugging/_uploader.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,24 +118,27 @@ def info_check(self, agent_info: Optional[dict[str, Any]]) -> bool:
118118
log.debug("Unsupported Datadog agent detected. Please upgrade to 7.49.0.")
119119
return False
120120

121-
endpoints = set(agent_info.get("endpoints", []))
121+
# Agent /info entries may or may not carry a leading slash depending on
122+
# the agent version, so normalize before matching (see remoteconfig).
123+
endpoints = {endpoint.lstrip("/") for endpoint in agent_info.get("endpoints", [])}
122124

123125
logs_track = self._tracks[SignalTrack.LOGS]
124126
snapshot_track = self._tracks[SignalTrack.SNAPSHOT]
125127
logs_track.enabled = True
126128
snapshot_track.enabled = True
127129

128-
if "/debugger/v2/input" in endpoints:
130+
if "debugger/v2/input" in endpoints:
129131
log.debug("Detected /debugger/v2/input endpoint")
130132
logs_track.endpoint = f"/debugger/v2/input{self._endpoint_suffix}"
131133
snapshot_track.endpoint = f"/debugger/v2/input{self._endpoint_suffix}"
132-
elif "/debugger/v1/diagnostics" in endpoints:
134+
elif "debugger/v1/diagnostics" in endpoints:
133135
log.debug("Detected /debugger/v1/diagnostics endpoint fallback")
134136
logs_track.endpoint = f"/debugger/v1/diagnostics{self._endpoint_suffix}"
135137
snapshot_track.endpoint = f"/debugger/v1/diagnostics{self._endpoint_suffix}"
136138
else:
137139
logs_track.enabled = False
138140
snapshot_track.enabled = False
141+
self._throttle_agent_check()
139142
log.warning(
140143
UNSUPPORTED_AGENT,
141144
extra={
@@ -180,6 +183,7 @@ def upload(self) -> None:
180183

181184
def reset(self) -> None:
182185
"""Reset the buffer on fork."""
186+
super().reset()
183187
for track in self._tracks.values():
184188
track.queue = self.__queue__(encoder=track.queue._encoder, on_full=self._on_buffer_full)
185189
self._collector._tracks = {t: ut.queue for t, ut in self._tracks.items()}

ddtrace/internal/agent.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
from ddtrace.internal.periodic import ForksafeAwakeablePeriodicService
88
from ddtrace.internal.process_tags import compute_base_hash
99
from ddtrace.internal.settings._agent import config
10+
from ddtrace.internal.utils.time import HourGlass
1011

1112
from .utils.http import get_connection
1213

1314

1415
log = get_logger(__name__)
1516

17+
# Bound how often /info is polled while the agent is unreachable or unsupported.
18+
AGENT_CHECK_INTERVAL = 60.0
19+
1620

1721
def process_info_headers(resp):
1822
try:
@@ -51,11 +55,25 @@ def __init__(self, interval: float = 0.0):
5155
super().__init__(interval=interval)
5256

5357
self._state = self._agent_check
58+
self._agent_check_throttle = HourGlass(duration=AGENT_CHECK_INTERVAL)
5459

5560
@abc.abstractmethod
5661
def info_check(self, agent_info: t.Optional[dict]) -> bool: ...
5762

63+
def _throttle_agent_check(self) -> None:
64+
"""Back off from checking the agent until the throttle window elapses.
65+
66+
Call this only for a confirmed unsupported agent (reachable, but not
67+
advertising the required endpoints). Transient ``/info`` failures and
68+
upload failures on a supported agent are left to recover on the next
69+
tick rather than being suppressed for ``AGENT_CHECK_INTERVAL``.
70+
"""
71+
self._agent_check_throttle.turn()
72+
5873
def _agent_check(self) -> None:
74+
if self._agent_check_throttle.trickling():
75+
return
76+
5977
try:
6078
agent_info = info()
6179
except Exception:
@@ -72,6 +90,12 @@ def _online(self) -> None:
7290
self._state = self._agent_check
7391
log.debug("Error during online operation, reverting to agent check", exc_info=True)
7492

93+
def reset(self) -> None:
94+
# On fork, re-check the agent immediately rather than inheriting the
95+
# parent's throttle window.
96+
super().reset()
97+
self._agent_check_throttle = HourGlass(duration=AGENT_CHECK_INTERVAL)
98+
7599
@abc.abstractmethod
76100
def online(self) -> None: ...
77101

tests/debugging/test_uploader.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,17 @@ def test_info_check_endpoint_selection():
114114
from ddtrace.debugging._signal.model import SignalTrack
115115
from ddtrace.debugging._uploader import SignalUploader
116116

117-
# Test 1: v2 endpoint available - both tracks use v2
117+
# Test 1: v2 endpoint available (agent reports endpoints without a leading
118+
# slash) - both tracks use v2
118119
uploader = SignalUploader(interval=LONG_INTERVAL)
119-
agent_info = {"endpoints": ["/debugger/v2/input", "/debugger/v1/diagnostics"]}
120+
agent_info = {"endpoints": ["debugger/v2/input", "debugger/v1/diagnostics"]}
120121
assert uploader.info_check(agent_info) is True
121122
assert uploader._tracks[SignalTrack.LOGS].enabled is True
122123
assert uploader._tracks[SignalTrack.SNAPSHOT].enabled is True
123124
assert "/debugger/v2/input" in uploader._tracks[SignalTrack.LOGS].endpoint
124125
assert "/debugger/v2/input" in uploader._tracks[SignalTrack.SNAPSHOT].endpoint
125126

126-
# Test 2: Only diagnostics available - both tracks fallback to diagnostics
127+
# Test 2: Only diagnostics available (leading-slash form) - both tracks fallback
127128
uploader = SignalUploader(interval=LONG_INTERVAL)
128129
agent_info = {"endpoints": ["/debugger/v1/diagnostics"]}
129130
assert uploader.info_check(agent_info) is True
@@ -313,6 +314,77 @@ def test_online_raises_when_tracks_disabled():
313314
uploader.online()
314315

315316

317+
def test_agent_check_is_throttled():
318+
# A missing/unsupported agent must not cause /info to be polled on every
319+
# periodic tick; the check is throttled independently of the upload interval.
320+
from ddtrace.internal import agent as agent_module
321+
322+
uploader = SignalUploader(interval=LONG_INTERVAL)
323+
agent_info = {"endpoints": ["/some/other/endpoint"]}
324+
325+
with patch.object(agent_module, "info", return_value=agent_info) as mock_info:
326+
uploader._agent_check()
327+
uploader._agent_check()
328+
329+
assert mock_info.call_count == 1
330+
assert uploader._state == uploader._agent_check
331+
332+
333+
def test_unreachable_agent_is_not_throttled():
334+
# A transient /info failure (agent restart/startup race) must not suppress
335+
# capability checks; only a confirmed unsupported agent is throttled.
336+
from ddtrace.internal import agent as agent_module
337+
338+
uploader = SignalUploader(interval=LONG_INTERVAL)
339+
340+
with patch.object(agent_module, "info", return_value=None) as mock_info:
341+
uploader._agent_check()
342+
uploader._agent_check()
343+
344+
assert mock_info.call_count == 2
345+
assert uploader._agent_check_throttle.trickling() is False
346+
347+
348+
def test_agent_check_recovers_after_online_failure():
349+
# A transient upload failure after a healthy online cycle must not be
350+
# throttled: the next tick should re-check the agent immediately.
351+
from ddtrace.internal import agent as agent_module
352+
353+
uploader = SignalUploader(interval=LONG_INTERVAL)
354+
agent_info = {"endpoints": ["debugger/v2/input"]}
355+
356+
with patch.object(agent_module, "info", return_value=agent_info) as mock_info:
357+
# Healthy cycle: go online and clear the throttle.
358+
uploader._agent_check()
359+
assert uploader._state == uploader._online
360+
assert mock_info.call_count == 1
361+
362+
# Simulate a transient upload failure reverting us to the agent check.
363+
with patch.object(uploader, "online", side_effect=ValueError("transient")):
364+
uploader._online()
365+
assert uploader._state == uploader._agent_check
366+
367+
# Recovery must happen on the next tick, not after AGENT_CHECK_INTERVAL.
368+
uploader._agent_check()
369+
assert mock_info.call_count == 2
370+
assert uploader._state == uploader._online
371+
372+
373+
def test_agent_check_throttle_reset_on_fork():
374+
# A forked child must not inherit the parent's throttle window.
375+
from ddtrace.internal import agent as agent_module
376+
377+
uploader = SignalUploader(interval=LONG_INTERVAL)
378+
agent_info = {"endpoints": ["/some/other/endpoint"]}
379+
380+
with patch.object(agent_module, "info", return_value=agent_info):
381+
uploader._agent_check()
382+
assert uploader._agent_check_throttle.trickling() is True
383+
384+
uploader.reset()
385+
assert uploader._agent_check_throttle.trickling() is False
386+
387+
316388
class _IsolatedUploader(MockSignalUploader):
317389
"""Subclass to isolate class-level _products/_instance state from production code."""
318390

0 commit comments

Comments
 (0)