Skip to content

Commit 17ee10c

Browse files
authored
chore(debugging): support probe mutability (#5389)
This change adds support for mutable probes to Dynamic Instrumentation. This means that the DI agent can now update installed probes to match whatever changes have been applied to an existing probe from the Dynamic Instrumentation UI. ## Implementation details Only certain probe fields are currently allowed to be mutated. We leverage the annotation capabilities of `attrs` to mark such fields with `eq=True` (in fact, we mark _immutable_ fields with `eq=False`). An exception is the `probe_id` field which, whilst marked as mutable, it is actually used for primary equality detection. ## Testing strategy To comply with the backing RFC, the tests have been extended to check that: - probes are indeed mutable in the intended way - probe status are re-emitted when a probe is mutated
1 parent aa3a538 commit 17ee10c

File tree

12 files changed

+258
-72
lines changed

12 files changed

+258
-72
lines changed

ddtrace/debugging/_debugger.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,7 @@ def _on_configuration(self, event, probes):
611611
# We didn't have the probe. This shouldn't have happened!
612612
log.error("Modified probe %r was not found in registry.", probe)
613613
continue
614+
self._probe_registry.update(probe)
614615

615616
return
616617

ddtrace/debugging/_encoding.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def _probe_details(probe):
103103
}
104104
return {
105105
"id": probe.probe_id,
106+
"version": probe.version,
106107
"location": location,
107108
}
108109

ddtrace/debugging/_probe/model.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ class CaptureLimits(object):
6666
max_fields = attr.ib(type=int, default=MAXFIELDS) # type: int
6767

6868

69-
@attr.s(hash=True)
69+
@attr.s
7070
class Probe(six.with_metaclass(abc.ABCMeta)):
7171
probe_id = attr.ib(type=str)
72+
version = attr.ib(type=int)
7273
tags = attr.ib(type=dict, eq=False)
7374
rate = attr.ib(type=float, eq=False)
7475
limiter = attr.ib(type=RateLimiter, init=False, repr=False, eq=False) # type: RateLimiter
@@ -83,6 +84,22 @@ def _(self):
8384
raise_on_exceed=False,
8485
)
8586

87+
def update(self, other):
88+
# type: (Probe) -> None
89+
"""Update the mutable fields from another probe."""
90+
if self.probe_id != other.probe_id:
91+
log.error("Probe ID mismatch when updating mutable fields")
92+
return
93+
94+
if self.version == other.version:
95+
return
96+
97+
for attrib in (_.name for _ in self.__attrs_attrs__ if _.eq):
98+
setattr(self, attrib, getattr(other, attrib))
99+
100+
def __hash__(self):
101+
return hash(self.probe_id)
102+
86103

87104
@attr.s
88105
class ProbeConditionMixin(object):
@@ -119,8 +136,8 @@ def location(self):
119136

120137
@attr.s
121138
class LineLocationMixin(ProbeLocationMixin):
122-
source_file = attr.ib(type=str, converter=_resolve_source_file) # type: ignore[misc]
123-
line = attr.ib(type=int)
139+
source_file = attr.ib(type=str, converter=_resolve_source_file, eq=False) # type: ignore[misc]
140+
line = attr.ib(type=int, eq=False)
124141

125142
def location(self):
126143
return (self.source_file, self.line)
@@ -135,8 +152,8 @@ class ProbeEvaluateTimingForMethod(object):
135152

136153
@attr.s
137154
class FunctionLocationMixin(ProbeLocationMixin):
138-
module = attr.ib(type=str)
139-
func_qname = attr.ib(type=str)
155+
module = attr.ib(type=str, eq=False)
156+
func_qname = attr.ib(type=str, eq=False)
140157
evaluate_at = attr.ib(type=ProbeEvaluateTimingForMethod)
141158

142159
def location(self):

ddtrace/debugging/_probe/registry.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def set_message(self, message):
3838
# type: (str) -> None
3939
self.message = message
4040

41+
def update(self, probe):
42+
# type: (Probe) -> None
43+
self.probe.update(probe)
44+
4145

4246
def _get_probe_location(probe):
4347
# type: (Probe) -> Optional[str]
@@ -86,6 +90,16 @@ def register(self, *probes):
8690

8791
self.logger.received(probe)
8892

93+
def update(self, probe):
94+
with self._lock:
95+
if probe not in self:
96+
logger.error("Attempted to update unregistered probe %s", probe.probe_id)
97+
return
98+
99+
self[probe.probe_id].update(probe)
100+
101+
self.log_probe_status(probe)
102+
89103
def set_installed(self, probe):
90104
# type: (Probe) -> None
91105
"""Set the installed flag for a probe."""
@@ -111,19 +125,29 @@ def set_error(self, probe, message):
111125
self[probe.probe_id].set_message(message)
112126
self.logger.error(probe, message)
113127

128+
def _log_probe_status_unlocked(self, entry):
129+
# type: (ProbeRegistryEntry) -> None
130+
if entry.installed:
131+
self.logger.installed(entry.probe)
132+
elif entry.exc_info:
133+
self.logger.error(entry.probe, exc_info=entry.exc_info)
134+
elif entry.message:
135+
self.logger.error(entry.probe, message=entry.message)
136+
else:
137+
self.logger.received(entry.probe)
138+
139+
def log_probe_status(self, probe):
140+
# type: (Probe) -> None
141+
"""Log the status of a probe using the status logger."""
142+
with self._lock:
143+
self._log_probe_status_unlocked(self[probe.probe_id])
144+
114145
def log_probes_status(self):
115146
# type: () -> None
116147
"""Log the status of all the probes using the status logger."""
117148
with self._lock:
118149
for entry in self.values():
119-
if entry.installed:
120-
self.logger.installed(entry.probe)
121-
elif entry.exc_info:
122-
self.logger.error(entry.probe, exc_info=entry.exc_info)
123-
elif entry.message:
124-
self.logger.error(entry.probe, message=entry.message)
125-
else:
126-
self.logger.received(entry.probe)
150+
self._log_probe_status_unlocked(entry)
127151

128152
def _remove_pending(self, probe):
129153
# type: (Probe) -> None

ddtrace/debugging/_probe/remoteconfig.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def probe_factory(attribs):
148148

149149
args = dict(
150150
probe_id=_id,
151+
version=attribs.get("version", 0),
151152
condition=_compile_expression(attribs.get("when")),
152153
tags=dict(_.split(":", 1) for _ in attribs.get("tags", [])),
153154
rate=rate,
@@ -175,6 +176,7 @@ def probe_factory(attribs):
175176
elif _type == ProbeType.METRIC_PROBE:
176177
args = dict(
177178
probe_id=_id,
179+
version=attribs.get("version", 0),
178180
condition=_compile_expression(attribs.get("when")),
179181
tags=dict(_.split(":", 1) for _ in attribs.get("tags", [])),
180182
name=attribs["metricName"],
@@ -189,6 +191,7 @@ def probe_factory(attribs):
189191
elif _type == ProbeType.SPAN_PROBE:
190192
args = dict(
191193
probe_id=_id,
194+
version=attribs.get("version", 0),
192195
condition=_compile_expression(attribs.get("when")),
193196
tags=dict(_.split(":", 1) for _ in attribs.get("tags", [])),
194197
rate=DEFAULT_PROBE_RATE, # TODO: should we take rate limit out of Probe?
@@ -237,9 +240,10 @@ def _next_status_update_timestamp(self):
237240
self._status_timestamp = time.time() + config.diagnostics_interval
238241

239242
def _dispatch_probe_events(self, prev_probes, next_probes):
243+
# type: (Dict[str, Probe], Dict[str, Probe]) -> None
240244
new_probes = [p for _, p in next_probes.items() if _ not in prev_probes]
241245
deleted_probes = [p for _, p in prev_probes.items() if _ not in next_probes]
242-
modified_probes = [] # DEV: Probes are currently immutable
246+
modified_probes = [p for _, p in next_probes.items() if _ in prev_probes and p != prev_probes[_]]
243247

244248
if deleted_probes:
245249
self._callback(ProbePollerEvent.DELETED_PROBES, deleted_probes)

ddtrace/debugging/_probe/status.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def _payload(self, probe, status, message, timestamp, exc_info=None):
3636
"debugger": {
3737
"diagnostics": {
3838
"probeId": probe.probe_id,
39+
"probeVersion": probe.version,
3940
"status": status,
4041
}
4142
},

tests/debugging/mocking.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ def remove_probes(self, *probes):
8282
# type: (Probe) -> None
8383
self._on_configuration(ProbePollerEvent.DELETED_PROBES, probes)
8484

85+
def modify_probes(self, *probes):
86+
# type: (Probe) -> None
87+
self._on_configuration(ProbePollerEvent.MODIFIED_PROBES, probes)
88+
8589
@property
8690
def test_queue(self):
8791
return self._collector.test_queue

tests/debugging/probe/test_model.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from os.path import relpath
44
from os.path import sep
55

6+
from ddtrace.debugging._expressions import DDExpression
7+
from ddtrace.debugging._expressions import dd_compile
68
from ddtrace.debugging._probe.model import _resolve_source_file
9+
from tests.debugging.utils import create_log_line_probe
710

811

912
def test_resolve_source_file():
@@ -16,3 +19,30 @@ def test_resolve_source_file():
1619
# Test that we fail if we have incomplete source paths
1720
_, _, child = rpath.partition(sep)
1821
assert _resolve_source_file(child) is None
22+
23+
24+
def test_mutability():
25+
before = create_log_line_probe(
26+
probe_id="test_mutability",
27+
version=1,
28+
condition=None,
29+
source_file="foo",
30+
line=1,
31+
template="",
32+
segments=[],
33+
)
34+
after = create_log_line_probe(
35+
probe_id="test_mutability",
36+
version=2,
37+
condition=DDExpression(dsl="True", callable=dd_compile(True)),
38+
source_file="foo",
39+
line=1,
40+
template="",
41+
segments=[],
42+
)
43+
44+
assert before != after
45+
46+
before.update(after)
47+
48+
assert before == after

tests/debugging/probe/test_registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,6 @@ def test_registry_location_error():
6363
"service": "test",
6464
"message": "Unable to resolve location information for probe 42",
6565
"ddsource": "dd_debugger",
66-
"debugger": {"diagnostics": {"probeId": 42, "status": "ERROR"}},
66+
"debugger": {"diagnostics": {"probeId": 42, "probeVersion": 0, "status": "ERROR"}},
6767
}
6868
]

tests/debugging/probe/test_remoteconfig.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def validate_events(expected):
205205
config_metadata("spanProbe_probe1"),
206206
{
207207
"id": "probe1",
208+
"version": 0,
208209
"type": ProbeType.SPAN_PROBE,
209210
"active": True,
210211
"tags": ["foo:bar"],
@@ -223,6 +224,7 @@ def validate_events(expected):
223224
config_metadata("metricProbe_probe2"),
224225
{
225226
"id": "probe2",
227+
"version": 1,
226228
"type": ProbeType.METRIC_PROBE,
227229
"tags": ["foo:bar"],
228230
"where": {"sourceFile": "tests/submod/stuff.p", "lines": ["36"]},
@@ -241,6 +243,7 @@ def validate_events(expected):
241243
config_metadata("logProbe_probe3"),
242244
{
243245
"id": "probe3",
246+
"version": 1,
244247
"type": ProbeType.LOG_PROBE,
245248
"tags": ["foo:bar"],
246249
"where": {"sourceFile": "tests/submod/stuff.p", "lines": ["36"]},
@@ -359,3 +362,57 @@ def test_parse_log_probe_default_rates():
359362
)
360363

361364
assert probe.rate == DEFAULT_PROBE_RATE
365+
366+
367+
def test_modified_probe_events(mock_config):
368+
events = []
369+
370+
def cb(e, ps):
371+
events.append((e, frozenset([p.probe_id if isinstance(p, Probe) else p for p in ps])))
372+
373+
mock_config.add_probes(
374+
[
375+
create_snapshot_line_probe(
376+
probe_id="probe1",
377+
version=1,
378+
source_file="tests/debugger/submod/stuff.py",
379+
line=36,
380+
condition=None,
381+
),
382+
]
383+
)
384+
385+
metadata = config_metadata()
386+
old_interval = config.diagnostics_interval
387+
config.diagnostics_interval = 0.5
388+
try:
389+
adapter = ProbeRCAdapter(cb)
390+
# Wait to allow the next call to the adapter to generate a status event
391+
sleep(0.5)
392+
adapter(metadata, {})
393+
394+
mock_config.add_probes(
395+
[
396+
create_snapshot_line_probe(
397+
probe_id="probe1",
398+
version=2,
399+
source_file="tests/debugger/submod/stuff.py",
400+
line=36,
401+
condition=None,
402+
)
403+
]
404+
)
405+
adapter(metadata, {})
406+
407+
# Wait to allow the next call to the adapter to generate a status event
408+
sleep(0.5)
409+
adapter(metadata, {})
410+
411+
assert events == [
412+
(ProbePollerEvent.STATUS_UPDATE, frozenset()),
413+
(ProbePollerEvent.NEW_PROBES, frozenset(["probe1"])),
414+
(ProbePollerEvent.MODIFIED_PROBES, frozenset(["probe1"])),
415+
(ProbePollerEvent.STATUS_UPDATE, frozenset(["probe1"])),
416+
]
417+
finally:
418+
config.diagnostics_interval = old_interval

0 commit comments

Comments
 (0)