Skip to content

Commit eed9ba0

Browse files
committed
chore(debugger): implement Phase 1 guardrail foundations
Addresses the Phase 1 gaps from the Live Debugger Circuit-Breakers RFC: **Configurable time budgets** - Add `DD_DYNAMIC_INSTRUMENTATION_EVALUATION_TIMEOUT_MS` (default 10 ms) for condition and template expression evaluation wall-time budget - Add `DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS` (default 150 ms) to make the existing hardcoded 200 ms capture budget configurable - Register both keys in `supported-configurations.json` and regenerate `_supported_configurations.py` **Probe-entry skip on repeated evaluation errors** - Add `_error_throttled_until` timestamp to `ProbeConditionMixin`; when in the future, `_eval_condition` returns immediately without evaluating the condition - Evaluation overruns are treated as errors and fed into `condition_error_limiter` (same path as `DDExpressionEvaluationError`), so sustained slow conditions eventually trigger the same probe-entry skip as repeated runtime errors - Track `_eval_duration_ms` on `Signal` and `_capture_duration_ms` / `_template_eval_duration_ms` on `Snapshot` for observability **Canonical RFC metric names and reason codes (Appendix B)** - Replace old `skip` / `encoder.buffer.full` metrics with the canonical `dynamic_instrumentation.guardrails.*` namespace - `SKIP_RATE` split into `SKIP_RATE_GLOBAL` and `SKIP_RATE_PROBE` to map to `rateLimitGlobal` / `rateLimitProbe` reason codes - New skip reasons: `evaluationErrorThrottled`, `budgetExceededInvocation`, `queueFull` - New distribution metrics: `evaluation.duration` (condition / template) and `capture.duration` (with `truncated` tag) - Remove unused `_probe_type` helper and `_PROBE_TYPE_ATTR` constant from collector; inline `type(signal.probe).__name__` at call sites
1 parent a819241 commit eed9ba0

10 files changed

Lines changed: 663 additions & 29 deletions

File tree

ddtrace/debugging/_probe/model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ class ProbeConditionMixin(AbstractProbeMixIn):
139139
condition: Optional[DDExpression]
140140
condition_error_rate: float = field(compare=False)
141141
condition_error_limiter: RateLimiter = field(init=False, repr=False, compare=False)
142+
# monotonic timestamp before which evaluation should be skipped at probe entry (RFC: probe-entry skip)
143+
_error_throttled_until: float = field(default=0.0, init=False, repr=False, compare=False)
142144

143145
def __post_init__(self) -> None:
144146
super().__post_init__()

ddtrace/debugging/_signal/collector.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ddtrace.debugging._signal.model import Signal
99
from ddtrace.debugging._signal.model import SignalState
1010
from ddtrace.debugging._signal.model import SignalTrack
11+
from ddtrace.debugging._signal.snapshot import Snapshot
1112
from ddtrace.internal._encoding import BufferFull
1213
from ddtrace.internal.compat import ExcInfoType
1314
from ddtrace.internal.logger import get_logger
@@ -42,22 +43,69 @@ def _enqueue(self, log_signal: LogSignal) -> None:
4243
self._tracks[log_signal.__track__].put(log_signal)
4344
except BufferFull:
4445
log.debug("Encoder buffer full")
45-
meter.increment("encoder.buffer.full")
46+
meter.increment(
47+
"dynamic_instrumentation.guardrails.events.dropped",
48+
tags={"reason": "queueFull", "event_type": "snapshot"},
49+
)
4650
except KeyError:
4751
log.error("No encoder for signal track %s", log_signal.__track__)
4852

4953
def push(self, signal: Signal) -> None:
5054
if signal.state is SignalState.SKIP_COND:
51-
meter.increment("skip", tags={"cause": "cond", "probe_id": signal.probe.probe_id})
52-
elif signal.state in {SignalState.SKIP_COND_ERROR, SignalState.COND_ERROR}:
53-
meter.increment("skip", tags={"cause": "cond_error", "probe_id": signal.probe.probe_id})
54-
elif signal.state is SignalState.SKIP_RATE:
55-
meter.increment("skip", tags={"cause": "rate", "probe_id": signal.probe.probe_id})
55+
# Condition evaluated to False — not a guardrail event, no metric
56+
pass
57+
elif signal.state is SignalState.SKIP_COND_ERROR:
58+
meter.increment(
59+
"dynamic_instrumentation.guardrails.events.skipped",
60+
tags={"reason": "evaluationErrorThrottled", "probe_type": type(signal.probe).__name__},
61+
)
62+
elif signal.state is SignalState.COND_ERROR:
63+
meter.increment(
64+
"dynamic_instrumentation.guardrails.evaluation.errors",
65+
tags={"probe_type": type(signal.probe).__name__, "error_kind": "condition"},
66+
)
67+
elif signal.state is SignalState.SKIP_RATE_GLOBAL:
68+
meter.increment(
69+
"dynamic_instrumentation.guardrails.events.skipped",
70+
tags={"reason": "rateLimitGlobal", "probe_type": type(signal.probe).__name__},
71+
)
72+
elif signal.state is SignalState.SKIP_RATE_PROBE:
73+
meter.increment(
74+
"dynamic_instrumentation.guardrails.events.skipped",
75+
tags={"reason": "rateLimitProbe", "probe_type": type(signal.probe).__name__},
76+
)
5677
elif signal.state is SignalState.SKIP_BUDGET:
57-
meter.increment("skip", tags={"cause": "budget", "probe_id": signal.probe.probe_id})
78+
meter.increment(
79+
"dynamic_instrumentation.guardrails.events.skipped",
80+
tags={"reason": "budgetExceededInvocation", "probe_type": type(signal.probe).__name__},
81+
)
5882
elif signal.state is SignalState.DONE:
5983
meter.increment("signal", tags={"probe_id": signal.probe.probe_id})
6084

85+
# Emit evaluation duration if measured
86+
if signal._eval_duration_ms is not None:
87+
meter.distribution(
88+
"dynamic_instrumentation.guardrails.evaluation.duration",
89+
signal._eval_duration_ms,
90+
tags={"probe_type": type(signal.probe).__name__, "evaluation_kind": "condition"},
91+
)
92+
93+
# Emit capture and template evaluation durations for snapshots
94+
if isinstance(signal, Snapshot):
95+
if signal._template_eval_duration_ms is not None:
96+
meter.distribution(
97+
"dynamic_instrumentation.guardrails.evaluation.duration",
98+
signal._template_eval_duration_ms,
99+
tags={"probe_type": type(signal.probe).__name__, "evaluation_kind": "template"},
100+
)
101+
if signal._capture_duration_ms is not None:
102+
truncated = "true" if signal.errors else "false"
103+
meter.distribution(
104+
"dynamic_instrumentation.guardrails.capture.duration",
105+
signal._capture_duration_ms,
106+
tags={"probe_type": type(signal.probe).__name__, "truncated": truncated},
107+
)
108+
61109
if (
62110
isinstance(signal, LogSignal)
63111
and signal.state in {SignalState.DONE, SignalState.COND_ERROR}

ddtrace/debugging/_signal/model.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from ddtrace.internal.metrics import Metrics
2929
from ddtrace.internal.rate_limiter import BudgetRateLimiterWithJitter as RateLimiter
3030
from ddtrace.internal.rate_limiter import RateLimitExceeded
31+
from ddtrace.internal.settings.dynamic_instrumentation import config as di_config
32+
from ddtrace.internal.utils.time import Time
3133
from ddtrace.trace import Context
3234
from ddtrace.trace import Span
3335

@@ -42,7 +44,8 @@ class SignalState(str, Enum):
4244
NONE = "NONE"
4345
SKIP_COND = "SKIP_COND"
4446
SKIP_COND_ERROR = "SKIP_COND_ERROR"
45-
SKIP_RATE = "SKIP_RATE"
47+
SKIP_RATE_GLOBAL = "SKIP_RATE_GLOBAL"
48+
SKIP_RATE_PROBE = "SKIP_RATE_PROBE"
4649
SKIP_BUDGET = "SKIP_BUDGET"
4750
COND_ERROR = "COND_ERROR"
4851
DONE = "DONE"
@@ -86,6 +89,7 @@ class Signal(abc.ABC):
8689
errors: list[EvaluationError] = field(default_factory=list)
8790
timestamp: float = field(default_factory=time.time)
8891
uuid: str = field(default_factory=lambda: str(uuid4()), init=False)
92+
_eval_duration_ms: Optional[float] = field(default=None, init=False, repr=False)
8993

9094
def __post_init__(self) -> None:
9195
probe = self.probe
@@ -106,19 +110,47 @@ def _eval_condition(self, scope: Mapping[str, Any]) -> bool:
106110
if condition is None:
107111
return True
108112

113+
# RFC: skip at probe entry when error rate has been exceeded previously
114+
if Time.monotonic() < probe._error_throttled_until:
115+
self.state = SignalState.SKIP_COND_ERROR
116+
return False
117+
118+
t_start = Time.monotonic()
109119
try:
110-
if bool(condition.eval(scope)):
111-
return True
120+
result = bool(condition.eval(scope))
112121
except DDExpressionEvaluationError as e:
113122
self.errors.append(EvaluationError(expr=e.dsl, message=e.error))
114-
self.state = (
115-
SignalState.SKIP_COND_ERROR
116-
if probe.condition_error_limiter.limit() is RateLimitExceeded
117-
else SignalState.COND_ERROR
123+
if probe.condition_error_limiter.limit() is RateLimitExceeded:
124+
probe._error_throttled_until = Time.monotonic() + probe.condition_error_limiter.tau
125+
self.state = SignalState.SKIP_COND_ERROR
126+
else:
127+
self.state = SignalState.COND_ERROR
128+
return False
129+
finally:
130+
self._eval_duration_ms = (Time.monotonic() - t_start) * 1000
131+
132+
# RFC: treat evaluation overruns as guardrail input — feed into the error throttle
133+
eval_timeout_ms = di_config.evaluation_timeout_ms
134+
if self._eval_duration_ms > eval_timeout_ms:
135+
self.errors.append(
136+
EvaluationError(
137+
expr=condition.dsl,
138+
message=(
139+
f"Condition evaluation exceeded budget: {self._eval_duration_ms:.1f}ms > {eval_timeout_ms}ms"
140+
),
141+
)
118142
)
119-
else:
120-
self.state = SignalState.SKIP_COND
143+
if probe.condition_error_limiter.limit() is RateLimitExceeded:
144+
probe._error_throttled_until = Time.monotonic() + probe.condition_error_limiter.tau
145+
self.state = SignalState.SKIP_COND_ERROR
146+
return False
147+
self.state = SignalState.COND_ERROR
148+
return False
149+
150+
if result:
151+
return True
121152

153+
self.state = SignalState.SKIP_COND
122154
return False
123155

124156
def _rate_limit_exceeded(self) -> bool:
@@ -130,7 +162,7 @@ def _rate_limit_exceeded(self) -> bool:
130162

131163
exceeded = self.session is None and probe.limiter.limit() is RateLimitExceeded
132164
if exceeded:
133-
self.state = SignalState.SKIP_RATE
165+
self.state = SignalState.SKIP_RATE_PROBE
134166

135167
return exceeded
136168

@@ -239,7 +271,7 @@ def do_line(self, global_limiter: Optional[RateLimiter] = None) -> None:
239271
return
240272

241273
if global_limiter is not None and global_limiter.limit() is RateLimitExceeded:
242-
self.state = SignalState.SKIP_RATE
274+
self.state = SignalState.SKIP_RATE_GLOBAL
243275
return
244276

245277
if self._rate_limit_exceeded():

ddtrace/debugging/_signal/snapshot.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,9 @@
3636
from ddtrace.internal.compat import NO_EXCEPTION
3737
from ddtrace.internal.compat import ExcInfoType
3838
from ddtrace.internal.metrics import Metrics
39+
from ddtrace.internal.settings.dynamic_instrumentation import config as di_config
3940
from ddtrace.internal.utils.time import HourGlass
40-
41-
42-
CAPTURE_TIME_BUDGET = 0.2 # seconds
41+
from ddtrace.internal.utils.time import Time
4342

4443

4544
_NOTSET = object()
@@ -54,7 +53,7 @@ def _capture_context(
5453
retval: Any = _NOTSET,
5554
limits: CaptureLimits = DEFAULT_CAPTURE_LIMITS,
5655
) -> dict[str, Any]:
57-
with HourGlass(duration=CAPTURE_TIME_BUDGET) as hg:
56+
with HourGlass(duration=di_config.capture_timeout_ms / 1000) as hg:
5857

5958
def timeout(_: Any) -> bool:
6059
return not hg.trickling()
@@ -93,7 +92,7 @@ def _capture_expressions(
9392
exprs: list[CaptureExpression],
9493
scope: Mapping[str, Any],
9594
) -> dict[str, Any]:
96-
with HourGlass(duration=CAPTURE_TIME_BUDGET) as hg:
95+
with HourGlass(duration=di_config.capture_timeout_ms / 1000) as hg:
9796

9897
def timeout(_: Any) -> bool:
9998
return not hg.trickling()
@@ -131,6 +130,8 @@ class Snapshot(LogSignal):
131130
_stack: Optional[list[dict[str, Any]]] = field(default=None)
132131
_message: Optional[str] = field(default=None)
133132
duration: Optional[int] = field(default=None) # nanoseconds
133+
_capture_duration_ms: Optional[float] = field(default=None, init=False, repr=False)
134+
_template_eval_duration_ms: Optional[float] = field(default=None, init=False, repr=False)
134135

135136
def _eval_segment(self, segment: TemplateSegment, _locals: Mapping[str, Any]) -> str:
136137
probe = cast(LogProbeMixin, self.probe)
@@ -151,7 +152,21 @@ def _eval_segment(self, segment: TemplateSegment, _locals: Mapping[str, Any]) ->
151152

152153
def _eval_message(self, _locals: Mapping[str, Any]) -> None:
153154
probe = cast(LogProbeMixin, self.probe)
155+
t_start = Time.monotonic()
154156
self._message = "".join([self._eval_segment(s, _locals) for s in probe.segments])
157+
self._template_eval_duration_ms = (Time.monotonic() - t_start) * 1000
158+
# RFC: treat overruns as guardrail input — record as evaluation error
159+
eval_timeout_ms = di_config.evaluation_timeout_ms
160+
if self._template_eval_duration_ms > eval_timeout_ms:
161+
self.errors.append(
162+
EvaluationError(
163+
expr=probe.template,
164+
message=(
165+
f"Template evaluation exceeded budget: "
166+
f"{self._template_eval_duration_ms:.1f}ms > {eval_timeout_ms}ms"
167+
),
168+
)
169+
)
155170

156171
def _do(self, retval: Any, exc_info: ExcInfoType, scope: Mapping[str, Any]) -> Optional[dict[str, Any]]:
157172
probe = cast(LogProbeMixin, self.probe)
@@ -162,10 +177,16 @@ def _do(self, retval: Any, exc_info: ExcInfoType, scope: Mapping[str, Any]) -> O
162177
self._stack = utils.capture_stack(self.frame)
163178

164179
if probe.take_snapshot:
165-
return _capture_context(frame, exc_info, retval=retval, limits=probe.limits)
180+
t_start = Time.monotonic()
181+
result = _capture_context(frame, exc_info, retval=retval, limits=probe.limits)
182+
self._capture_duration_ms = (Time.monotonic() - t_start) * 1000
183+
return result
166184

167185
if probe.capture_expressions:
168-
return _capture_expressions(probe.capture_expressions, scope)
186+
t_start = Time.monotonic()
187+
result = _capture_expressions(probe.capture_expressions, scope)
188+
self._capture_duration_ms = (Time.monotonic() - t_start) * 1000
189+
return result
169190

170191
return None
171192

ddtrace/internal/settings/_supported_configurations.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,10 @@
194194
"DD_DOWNLOAD_MAX_RETRIES",
195195
"DD_DRAMATIQ_SERVICE",
196196
"DD_DURABLE_CROSS_INVOCATION_TRACING_ENABLED",
197+
"DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS",
197198
"DD_DYNAMIC_INSTRUMENTATION_DIAGNOSTICS_INTERVAL",
198199
"DD_DYNAMIC_INSTRUMENTATION_ENABLED",
200+
"DD_DYNAMIC_INSTRUMENTATION_EVALUATION_TIMEOUT_MS",
199201
"DD_DYNAMIC_INSTRUMENTATION_MAX_PAYLOAD_SIZE",
200202
"DD_DYNAMIC_INSTRUMENTATION_METRICS_ENABLED",
201203
"DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE",

ddtrace/internal/settings/dynamic_instrumentation.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,22 @@ class DynamicInstrumentationConfig(DDConfig):
142142
help="List of identifiers to exclude from redaction",
143143
)
144144

145+
capture_timeout_ms = DDConfig.v(
146+
int,
147+
"capture.timeout_ms",
148+
default=150,
149+
help_type="Integer",
150+
help="Maximum wall-time in milliseconds for snapshot capture and serialization per probe invocation",
151+
)
152+
153+
evaluation_timeout_ms = DDConfig.v(
154+
int,
155+
"evaluation.timeout_ms",
156+
default=10,
157+
help_type="Integer",
158+
help="Maximum wall-time in milliseconds for condition and template expression evaluation per probe invocation",
159+
)
160+
145161
probe_file = DDConfig.v(
146162
t.Optional[Path],
147163
"probe_file",

supported-configurations.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,6 +1446,13 @@
14461446
]
14471447
}
14481448
],
1449+
"DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS": [
1450+
{
1451+
"implementation": "A",
1452+
"type": "int",
1453+
"default": "150"
1454+
}
1455+
],
14491456
"DD_DYNAMIC_INSTRUMENTATION_DIAGNOSTICS_INTERVAL": [
14501457
{
14511458
"implementation": "A",
@@ -1460,6 +1467,13 @@
14601467
"default": "false"
14611468
}
14621469
],
1470+
"DD_DYNAMIC_INSTRUMENTATION_EVALUATION_TIMEOUT_MS": [
1471+
{
1472+
"implementation": "A",
1473+
"type": "int",
1474+
"default": "10"
1475+
}
1476+
],
14631477
"DD_DYNAMIC_INSTRUMENTATION_MAX_PAYLOAD_SIZE": [
14641478
{
14651479
"implementation": "B",

0 commit comments

Comments
 (0)