Skip to content

feat(di): add capture expressions #14235

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions ddtrace/debugging/_exception/replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def build(cls, exc_id: uuid.UUID, frame: FrameType) -> "SpanExceptionProbe":
template=message,
segments=[LiteralTemplateSegment(message)],
take_snapshot=True,
capture_expressions=[],
limits=DEFAULT_CAPTURE_LIMITS,
condition=None,
condition_error_rate=0.0,
Expand Down
2 changes: 2 additions & 0 deletions ddtrace/debugging/_origin/span.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def build(cls, name: str, module: str, function: str) -> "EntrySpanProbe":
template=message,
segments=[LiteralTemplateSegment(message)],
take_snapshot=True,
capture_expressions=[],
limits=DEFAULT_CAPTURE_LIMITS,
condition=None,
condition_error_rate=0.0,
Expand All @@ -89,6 +90,7 @@ def build(cls, name: str, filename: str, line: int) -> "ExitSpanProbe":
template=message,
segments=[LiteralTemplateSegment(message)],
take_snapshot=True,
capture_expressions=[],
limits=DEFAULT_CAPTURE_LIMITS,
condition=None,
condition_error_rate=0.0,
Expand Down
13 changes: 10 additions & 3 deletions ddtrace/debugging/_probe/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,28 +245,35 @@ def _to_str(value):
return "".join([_to_str(s.eval(scope)) for s in self.segments])


@dataclass
class CaptureExpression:
name: str
expr: DDExpression


@dataclass
class LogProbeMixin(AbstractProbeMixIn):
template: str
segments: List[TemplateSegment]
take_snapshot: bool
capture_expressions: List[CaptureExpression]
limits: CaptureLimits = field(compare=False)

@property
def __budget__(self) -> int:
return 10 if self.take_snapshot else 100
return 10 if self.take_snapshot or self.capture_expressions else 100


@dataclass(eq=False)
class LogLineProbe(Probe, LineLocationMixin, LogProbeMixin, ProbeConditionMixin, RateLimitMixin):
def is_global_rate_limited(self) -> bool:
return self.take_snapshot
return self.take_snapshot or bool(self.capture_expressions)


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


@dataclass
Expand Down
2 changes: 2 additions & 0 deletions ddtrace/debugging/_probe/remoteconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ddtrace.debugging._probe.model import DEFAULT_PROBE_RATE
from ddtrace.debugging._probe.model import DEFAULT_SNAPSHOT_PROBE_RATE
from ddtrace.debugging._probe.model import DEFAULT_TRIGGER_PROBE_RATE
from ddtrace.debugging._probe.model import CaptureExpression
from ddtrace.debugging._probe.model import CaptureLimits
from ddtrace.debugging._probe.model import ExpressionTemplateSegment
from ddtrace.debugging._probe.model import FunctionProbe
Expand Down Expand Up @@ -146,6 +147,7 @@ def update_args(cls, args, attribs):
else DEFAULT_CAPTURE_LIMITS,
condition_error_rate=DEFAULT_PROBE_CONDITION_ERROR_RATE, # TODO: should we take rate limit out of Probe?
take_snapshot=take_snapshot,
capture_expressions=[CaptureExpression(**_) for _ in attribs.get("captureExpressions", [])],
template=attribs.get("template"),
segments=[_compile_segment(segment) for segment in attribs.get("segments", [])],
)
Expand Down
32 changes: 30 additions & 2 deletions ddtrace/debugging/_signal/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
from types import ModuleType
from typing import Any
from typing import Dict
from typing import List
from typing import Mapping
from typing import Optional
from typing import cast

from ddtrace.debugging._expressions import DDExpressionEvaluationError
from ddtrace.debugging._probe.model import DEFAULT_CAPTURE_LIMITS
from ddtrace.debugging._probe.model import CaptureExpression
from ddtrace.debugging._probe.model import CaptureLimits
from ddtrace.debugging._probe.model import FunctionLocationMixin
from ddtrace.debugging._probe.model import LineLocationMixin
Expand Down Expand Up @@ -85,6 +87,26 @@ def timeout(_):
}


def _capture_expressions(
exprs: List[CaptureExpression],
scope: Mapping[str, Any],
limits: CaptureLimits = DEFAULT_CAPTURE_LIMITS,
) -> Dict[str, Any]:
with HourGlass(duration=CAPTURE_TIME_BUDGET) as hg:

def timeout(_):
return not hg.trickling()

return {
"captureExpressions": {
e.name: utils.capture_value(
e.expr.eval(scope), limits.max_level, limits.max_len, limits.max_size, limits.max_fields, timeout
)
for e in exprs
}
}


_EMPTY_CAPTURED_CONTEXT: Dict[str, Any] = {"arguments": {}, "locals": {}, "staticFields": {}, "throwable": None}


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

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

return _capture_context(frame, exc_info, retval=retval, limits=probe.limits) if probe.take_snapshot else None
if probe.take_snapshot:
return _capture_context(frame, exc_info, retval=retval, limits=probe.limits)

if probe.capture_expressions:
return _capture_expressions(probe.capture_expressions, scope, probe.limits)

return None

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

captures = {}
if isinstance(probe, LogProbeMixin) and probe.take_snapshot:
if isinstance(probe, LogProbeMixin) and (probe.take_snapshot or probe.capture_expressions):
if isinstance(probe, LineLocationMixin):
captures = {"lines": {str(probe.line): self.line_capture or _EMPTY_CAPTURED_CONTEXT}}
elif isinstance(probe, FunctionLocationMixin):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
features:
- |
dynamic instrumentation: added support for capture expressions in log
probes.
80 changes: 80 additions & 0 deletions tests/debugging/test_debugger.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
from ddtrace.internal.utils.formats import format_trace_id
from ddtrace.internal.utils.inspection import linenos
from tests.debugging.mocking import debugger
from tests.debugging.utils import compile_capture_expressions
from tests.debugging.utils import compile_template
from tests.debugging.utils import create_capture_expressions_function_probe
from tests.debugging.utils import create_capture_expressions_line_probe
from tests.debugging.utils import create_log_function_probe
from tests.debugging.utils import create_log_line_probe
from tests.debugging.utils import create_metric_line_probe
Expand Down Expand Up @@ -916,6 +919,83 @@ def test_debugger_log_line_probe_generate_messages(stuff):
assert not msg1["debugger"]["snapshot"]["captures"]


def test_debugger_capture_expressions_line_probe_(stuff):
with debugger(upload_flush_interval=float("inf")) as d:
d.add_probes(
create_capture_expressions_line_probe(
probe_id="foo",
source_file="tests/submod/stuff.py",
line=36,
rate=float("inf"),
**compile_capture_expressions([{"name": "foo", "expr": {"dsl": "bar", "json": {"ref": "bar"}}}]),
),
)

sentinel = {"foo": 42}
# recursive reference to itself for infinite depth
sentinel["self"] = sentinel # type: ignore

stuff.Stuff().instancestuff(sentinel)

(msg,) = d.uploader.wait_for_payloads(1)

assert msg["debugger"]["snapshot"]["captures"]["lines"]["36"] == {
"captureExpressions": {
"foo": {
"type": "dict",
"entries": [
[{"type": "str", "value": "'foo'"}, {"type": "int", "value": "42"}],
[
{"type": "str", "value": "'self'"},
{
"type": "dict",
"entries": [
[{"type": "str", "value": "'foo'"}, {"type": "int", "value": "42"}],
[
{"type": "str", "value": "'self'"},
{
"type": "dict",
"entries": [
[{"type": "str", "value": "'foo'"}, {"type": "int", "value": "42"}],
[
{"type": "str", "value": "'self'"},
{"type": "dict", "notCapturedReason": "depth", "size": 2},
],
],
"size": 2,
},
],
],
"size": 2,
},
],
],
"size": 2,
}
}
}


def test_debugger_capture_expressions_function_probe(stuff):
with debugger() as d:
d.add_probes(
create_capture_expressions_function_probe(
probe_id="exit-probe",
module="tests.submod.stuff",
func_qname="mutator",
evaluate_at=ProbeEvalTiming.EXIT,
**compile_capture_expressions(
[{"name": "retval", "expr": {"dsl": "@return", "json": {"ref": "@return"}}}]
),
)
)

stuff.mutator(arg=[])

with d.assert_single_snapshot() as snapshot:
assert snapshot.return_capture["captureExpressions"]["retval"] == {"isNull": True, "type": "NoneType"}


class SpanProbeTestCase(TracerTestCase):
def setUp(self):
super(SpanProbeTestCase, self).setUp()
Expand Down
38 changes: 38 additions & 0 deletions tests/debugging/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ddtrace.debugging._probe.model import DEFAULT_PROBE_CONDITION_ERROR_RATE
from ddtrace.debugging._probe.model import DEFAULT_PROBE_RATE
from ddtrace.debugging._probe.model import DEFAULT_SNAPSHOT_PROBE_RATE
from ddtrace.debugging._probe.model import CaptureExpression
from ddtrace.debugging._probe.model import ExpressionTemplateSegment
from ddtrace.debugging._probe.model import LiteralTemplateSegment
from ddtrace.debugging._probe.model import LogFunctionProbe
Expand Down Expand Up @@ -37,6 +38,14 @@ def compile_template(*args):
return {"template": template, "segments": segments}


def compile_capture_expressions(exprs):
return {
"capture_expressions": [
CaptureExpression(name=expr["name"], expr=DDRedactedExpression.compile(expr["expr"])) for expr in exprs
],
}


def ddexpr(json, dsl="test"):
return DDExpression(dsl=dsl, callable=dd_compile(json))

Expand Down Expand Up @@ -76,6 +85,7 @@ def _wrapper(*args, **kwargs):
kwargs.setdefault("take_snapshot", False)
kwargs.setdefault("rate", DEFAULT_PROBE_RATE)
kwargs.setdefault("limits", DEFAULT_CAPTURE_LIMITS)
kwargs.setdefault("capture_expressions", [])
return f(*args, **kwargs)

return _wrapper
Expand All @@ -88,6 +98,19 @@ def _wrapper(*args, **kwargs):
kwargs.setdefault("limits", DEFAULT_CAPTURE_LIMITS)
kwargs.setdefault("template", "")
kwargs.setdefault("segments", [])
kwargs.setdefault("capture_expressions", [])
return f(*args, **kwargs)

return _wrapper


def capture_expressions_probe_defaults(f):
def _wrapper(*args, **kwargs):
kwargs.setdefault("take_snapshot", False)
kwargs.setdefault("rate", DEFAULT_SNAPSHOT_PROBE_RATE)
kwargs.setdefault("limits", DEFAULT_CAPTURE_LIMITS)
kwargs.setdefault("template", "")
kwargs.setdefault("segments", [])
return f(*args, **kwargs)

return _wrapper
Expand Down Expand Up @@ -142,6 +165,21 @@ def create_snapshot_function_probe(**kwargs):
return LogFunctionProbe(**kwargs)


@create_probe_defaults
@probe_conditional_defaults
@capture_expressions_probe_defaults
def create_capture_expressions_line_probe(**kwargs):
return LogLineProbe(**kwargs)


@create_probe_defaults
@probe_conditional_defaults
@timing_defaults
@capture_expressions_probe_defaults
def create_capture_expressions_function_probe(**kwargs):
return LogFunctionProbe(**kwargs)


@create_probe_defaults
@probe_conditional_defaults
@log_probe_defaults
Expand Down
Loading