Skip to content

Commit f32d5b1

Browse files
committed
feat(di): add capture expressions
We add support for the new capture expression feature that allows capturing specific values instead of the whole scope snapshot.
1 parent f225902 commit f32d5b1

File tree

8 files changed

+168
-5
lines changed

8 files changed

+168
-5
lines changed

ddtrace/debugging/_exception/replay.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def build(cls, exc_id: uuid.UUID, frame: FrameType) -> "SpanExceptionProbe":
159159
template=message,
160160
segments=[LiteralTemplateSegment(message)],
161161
take_snapshot=True,
162+
capture_expressions=[],
162163
limits=DEFAULT_CAPTURE_LIMITS,
163164
condition=None,
164165
condition_error_rate=0.0,

ddtrace/debugging/_origin/span.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def build(cls, name: str, module: str, function: str) -> "EntrySpanProbe":
6565
template=message,
6666
segments=[LiteralTemplateSegment(message)],
6767
take_snapshot=True,
68+
capture_expressions=[],
6869
limits=DEFAULT_CAPTURE_LIMITS,
6970
condition=None,
7071
condition_error_rate=0.0,
@@ -89,6 +90,7 @@ def build(cls, name: str, filename: str, line: int) -> "ExitSpanProbe":
8990
template=message,
9091
segments=[LiteralTemplateSegment(message)],
9192
take_snapshot=True,
93+
capture_expressions=[],
9294
limits=DEFAULT_CAPTURE_LIMITS,
9395
condition=None,
9496
condition_error_rate=0.0,

ddtrace/debugging/_probe/model.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,28 +245,35 @@ def _to_str(value):
245245
return "".join([_to_str(s.eval(scope)) for s in self.segments])
246246

247247

248+
@dataclass
249+
class CaptureExpression:
250+
name: str
251+
expr: DDExpression
252+
253+
248254
@dataclass
249255
class LogProbeMixin(AbstractProbeMixIn):
250256
template: str
251257
segments: List[TemplateSegment]
252258
take_snapshot: bool
259+
capture_expressions: List[CaptureExpression]
253260
limits: CaptureLimits = field(compare=False)
254261

255262
@property
256263
def __budget__(self) -> int:
257-
return 10 if self.take_snapshot else 100
264+
return 10 if self.take_snapshot or self.capture_expressions else 100
258265

259266

260267
@dataclass(eq=False)
261268
class LogLineProbe(Probe, LineLocationMixin, LogProbeMixin, ProbeConditionMixin, RateLimitMixin):
262269
def is_global_rate_limited(self) -> bool:
263-
return self.take_snapshot
270+
return self.take_snapshot or bool(self.capture_expressions)
264271

265272

266273
@dataclass(eq=False)
267274
class LogFunctionProbe(Probe, FunctionLocationMixin, TimingMixin, LogProbeMixin, ProbeConditionMixin, RateLimitMixin):
268275
def is_global_rate_limited(self) -> bool:
269-
return self.take_snapshot
276+
return self.take_snapshot or bool(self.capture_expressions)
270277

271278

272279
@dataclass

ddtrace/debugging/_probe/remoteconfig.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ddtrace.debugging._probe.model import DEFAULT_PROBE_RATE
1818
from ddtrace.debugging._probe.model import DEFAULT_SNAPSHOT_PROBE_RATE
1919
from ddtrace.debugging._probe.model import DEFAULT_TRIGGER_PROBE_RATE
20+
from ddtrace.debugging._probe.model import CaptureExpression
2021
from ddtrace.debugging._probe.model import CaptureLimits
2122
from ddtrace.debugging._probe.model import ExpressionTemplateSegment
2223
from ddtrace.debugging._probe.model import FunctionProbe
@@ -146,6 +147,7 @@ def update_args(cls, args, attribs):
146147
else DEFAULT_CAPTURE_LIMITS,
147148
condition_error_rate=DEFAULT_PROBE_CONDITION_ERROR_RATE, # TODO: should we take rate limit out of Probe?
148149
take_snapshot=take_snapshot,
150+
capture_expressions=[CaptureExpression(**_) for _ in attribs.get("captureExpressions", [])],
149151
template=attribs.get("template"),
150152
segments=[_compile_segment(segment) for segment in attribs.get("segments", [])],
151153
)

ddtrace/debugging/_signal/snapshot.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
from types import ModuleType
88
from typing import Any
99
from typing import Dict
10+
from typing import List
1011
from typing import Mapping
1112
from typing import Optional
1213
from typing import cast
1314

1415
from ddtrace.debugging._expressions import DDExpressionEvaluationError
1516
from ddtrace.debugging._probe.model import DEFAULT_CAPTURE_LIMITS
17+
from ddtrace.debugging._probe.model import CaptureExpression
1618
from ddtrace.debugging._probe.model import CaptureLimits
1719
from ddtrace.debugging._probe.model import FunctionLocationMixin
1820
from ddtrace.debugging._probe.model import LineLocationMixin
@@ -85,6 +87,26 @@ def timeout(_):
8587
}
8688

8789

90+
def _capture_expressions(
91+
exprs: List[CaptureExpression],
92+
scope: Mapping[str, Any],
93+
limits: CaptureLimits = DEFAULT_CAPTURE_LIMITS,
94+
) -> Dict[str, Any]:
95+
with HourGlass(duration=CAPTURE_TIME_BUDGET) as hg:
96+
97+
def timeout(_):
98+
return not hg.trickling()
99+
100+
return {
101+
"captureExpressions": {
102+
e.name: utils.capture_value(
103+
e.expr.eval(scope), limits.max_level, limits.max_len, limits.max_size, limits.max_fields, timeout
104+
)
105+
for e in exprs
106+
}
107+
}
108+
109+
88110
_EMPTY_CAPTURED_CONTEXT: Dict[str, Any] = {"arguments": {}, "locals": {}, "staticFields": {}, "throwable": None}
89111

90112

@@ -131,7 +153,13 @@ def _do(self, retval, exc_info, scope):
131153

132154
self._stack = utils.capture_stack(self.frame)
133155

134-
return _capture_context(frame, exc_info, retval=retval, limits=probe.limits) if probe.take_snapshot else None
156+
if probe.take_snapshot:
157+
return _capture_context(frame, exc_info, retval=retval, limits=probe.limits)
158+
159+
if probe.capture_expressions:
160+
return _capture_expressions(probe.capture_expressions, scope, probe.limits)
161+
162+
return None
135163

136164
def enter(self, scope: Mapping[str, Any]) -> None:
137165
self.entry_capture = self._do(_NOTSET, (None, None, None), scope)
@@ -166,7 +194,7 @@ def data(self):
166194
probe = self.probe
167195

168196
captures = {}
169-
if isinstance(probe, LogProbeMixin) and probe.take_snapshot:
197+
if isinstance(probe, LogProbeMixin) and (probe.take_snapshot or probe.capture_expressions):
170198
if isinstance(probe, LineLocationMixin):
171199
captures = {"lines": {str(probe.line): self.line_capture or _EMPTY_CAPTURED_CONTEXT}}
172200
elif isinstance(probe, FunctionLocationMixin):
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
dynamic instrumentation: added support for capture expressions in log
5+
probes.

tests/debugging/test_debugger.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
from ddtrace.internal.utils.formats import format_trace_id
3333
from ddtrace.internal.utils.inspection import linenos
3434
from tests.debugging.mocking import debugger
35+
from tests.debugging.utils import compile_capture_expressions
3536
from tests.debugging.utils import compile_template
37+
from tests.debugging.utils import create_capture_expressions_function_probe
38+
from tests.debugging.utils import create_capture_expressions_line_probe
3639
from tests.debugging.utils import create_log_function_probe
3740
from tests.debugging.utils import create_log_line_probe
3841
from tests.debugging.utils import create_metric_line_probe
@@ -916,6 +919,83 @@ def test_debugger_log_line_probe_generate_messages(stuff):
916919
assert not msg1["debugger"]["snapshot"]["captures"]
917920

918921

922+
def test_debugger_capture_expressions_line_probe_(stuff):
923+
with debugger(upload_flush_interval=float("inf")) as d:
924+
d.add_probes(
925+
create_capture_expressions_line_probe(
926+
probe_id="foo",
927+
source_file="tests/submod/stuff.py",
928+
line=36,
929+
rate=float("inf"),
930+
**compile_capture_expressions([{"name": "foo", "expr": {"dsl": "bar", "json": {"ref": "bar"}}}]),
931+
),
932+
)
933+
934+
sentinel = {"foo": 42}
935+
# recursive reference to itself for infinite depth
936+
sentinel["self"] = sentinel # type: ignore
937+
938+
stuff.Stuff().instancestuff(sentinel)
939+
940+
(msg,) = d.uploader.wait_for_payloads(1)
941+
942+
assert msg["debugger"]["snapshot"]["captures"]["lines"]["36"] == {
943+
"captureExpressions": {
944+
"foo": {
945+
"type": "dict",
946+
"entries": [
947+
[{"type": "str", "value": "'foo'"}, {"type": "int", "value": "42"}],
948+
[
949+
{"type": "str", "value": "'self'"},
950+
{
951+
"type": "dict",
952+
"entries": [
953+
[{"type": "str", "value": "'foo'"}, {"type": "int", "value": "42"}],
954+
[
955+
{"type": "str", "value": "'self'"},
956+
{
957+
"type": "dict",
958+
"entries": [
959+
[{"type": "str", "value": "'foo'"}, {"type": "int", "value": "42"}],
960+
[
961+
{"type": "str", "value": "'self'"},
962+
{"type": "dict", "notCapturedReason": "depth", "size": 2},
963+
],
964+
],
965+
"size": 2,
966+
},
967+
],
968+
],
969+
"size": 2,
970+
},
971+
],
972+
],
973+
"size": 2,
974+
}
975+
}
976+
}
977+
978+
979+
def test_debugger_capture_expressions_function_probe(stuff):
980+
with debugger() as d:
981+
d.add_probes(
982+
create_capture_expressions_function_probe(
983+
probe_id="exit-probe",
984+
module="tests.submod.stuff",
985+
func_qname="mutator",
986+
evaluate_at=ProbeEvalTiming.EXIT,
987+
**compile_capture_expressions(
988+
[{"name": "retval", "expr": {"dsl": "@return", "json": {"ref": "@return"}}}]
989+
),
990+
)
991+
)
992+
993+
stuff.mutator(arg=[])
994+
995+
with d.assert_single_snapshot() as snapshot:
996+
assert snapshot.return_capture["captureExpressions"]["retval"] == {"isNull": True, "type": "NoneType"}
997+
998+
919999
class SpanProbeTestCase(TracerTestCase):
9201000
def setUp(self):
9211001
super(SpanProbeTestCase, self).setUp()

tests/debugging/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ddtrace.debugging._probe.model import DEFAULT_PROBE_CONDITION_ERROR_RATE
77
from ddtrace.debugging._probe.model import DEFAULT_PROBE_RATE
88
from ddtrace.debugging._probe.model import DEFAULT_SNAPSHOT_PROBE_RATE
9+
from ddtrace.debugging._probe.model import CaptureExpression
910
from ddtrace.debugging._probe.model import ExpressionTemplateSegment
1011
from ddtrace.debugging._probe.model import LiteralTemplateSegment
1112
from ddtrace.debugging._probe.model import LogFunctionProbe
@@ -37,6 +38,14 @@ def compile_template(*args):
3738
return {"template": template, "segments": segments}
3839

3940

41+
def compile_capture_expressions(exprs):
42+
return {
43+
"capture_expressions": [
44+
CaptureExpression(name=expr["name"], expr=DDRedactedExpression.compile(expr["expr"])) for expr in exprs
45+
],
46+
}
47+
48+
4049
def ddexpr(json, dsl="test"):
4150
return DDExpression(dsl=dsl, callable=dd_compile(json))
4251

@@ -76,6 +85,7 @@ def _wrapper(*args, **kwargs):
7685
kwargs.setdefault("take_snapshot", False)
7786
kwargs.setdefault("rate", DEFAULT_PROBE_RATE)
7887
kwargs.setdefault("limits", DEFAULT_CAPTURE_LIMITS)
88+
kwargs.setdefault("capture_expressions", [])
7989
return f(*args, **kwargs)
8090

8191
return _wrapper
@@ -88,6 +98,19 @@ def _wrapper(*args, **kwargs):
8898
kwargs.setdefault("limits", DEFAULT_CAPTURE_LIMITS)
8999
kwargs.setdefault("template", "")
90100
kwargs.setdefault("segments", [])
101+
kwargs.setdefault("capture_expressions", [])
102+
return f(*args, **kwargs)
103+
104+
return _wrapper
105+
106+
107+
def capture_expressions_probe_defaults(f):
108+
def _wrapper(*args, **kwargs):
109+
kwargs.setdefault("take_snapshot", False)
110+
kwargs.setdefault("rate", DEFAULT_SNAPSHOT_PROBE_RATE)
111+
kwargs.setdefault("limits", DEFAULT_CAPTURE_LIMITS)
112+
kwargs.setdefault("template", "")
113+
kwargs.setdefault("segments", [])
91114
return f(*args, **kwargs)
92115

93116
return _wrapper
@@ -142,6 +165,21 @@ def create_snapshot_function_probe(**kwargs):
142165
return LogFunctionProbe(**kwargs)
143166

144167

168+
@create_probe_defaults
169+
@probe_conditional_defaults
170+
@capture_expressions_probe_defaults
171+
def create_capture_expressions_line_probe(**kwargs):
172+
return LogLineProbe(**kwargs)
173+
174+
175+
@create_probe_defaults
176+
@probe_conditional_defaults
177+
@timing_defaults
178+
@capture_expressions_probe_defaults
179+
def create_capture_expressions_function_probe(**kwargs):
180+
return LogFunctionProbe(**kwargs)
181+
182+
145183
@create_probe_defaults
146184
@probe_conditional_defaults
147185
@log_probe_defaults

0 commit comments

Comments
 (0)