From 3926499d297863a2a1baf09640fb8307835b1f31 Mon Sep 17 00:00:00 2001 From: Tyler Finethy Date: Fri, 14 Nov 2025 10:52:28 -0500 Subject: [PATCH 1/6] fix(debugger): reduce code origin startup time Start with a benchmark to measure opportunities for improvements refs: DEBUG-4605 --- riotfile.py | 1 + tests/debugging/origin/test_span.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/riotfile.py b/riotfile.py index da36295eecc..1aa5aae58d8 100644 --- a/riotfile.py +++ b/riotfile.py @@ -657,6 +657,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT "httpretty": latest, "typing-extensions": latest, "pytest-asyncio": latest, + "pytest-benchmark": latest, }, pys=select_pys(), ), diff --git a/tests/debugging/origin/test_span.py b/tests/debugging/origin/test_span.py index 05287a1ce7d..8bebba8d583 100644 --- a/tests/debugging/origin/test_span.py +++ b/tests/debugging/origin/test_span.py @@ -1,5 +1,6 @@ from functools import partial from pathlib import Path +import time import typing as t import pytest @@ -169,3 +170,44 @@ def entry_call(): assert _exit.get_tag("_dd.code_origin.type") is None assert _exit.get_tag("_dd.code_origin.frames.0.file") is None assert _exit.get_tag("_dd.code_origin.frames.0.line") is None + + +def test_instrument_view_benchmark(benchmark): + """Benchmark instrument_view performance when wrapping functions.""" + MockSpanCodeOriginProcessorEntry.enable() + + try: + + def setup(): + """Create a unique function to wrap for each iteration.""" + + # Create a more realistic view function similar to Flask views + # with decorators, imports, and more complex code + def realistic_view(request_arg, *args, **kwargs): + """A realistic view function with actual code.""" + import json + import os + + data = {"status": "ok", "items": []} + for i in range(10): + item = { + "id": i, + "name": f"item_{i}", + "value": i * 100, + } + data["items"].append(item) + + result = json.dumps(data) + return result + + return (realistic_view,), {} + + # Benchmark the wrapping operation + benchmark.pedantic( + MockSpanCodeOriginProcessorEntry.instrument_view, + setup=setup, + rounds=100, + ) + + finally: + MockSpanCodeOriginProcessorEntry.disable() From db9478af025b782977441163ba4290131b82eff5 Mon Sep 17 00:00:00 2001 From: Tyler Finethy Date: Fri, 14 Nov 2025 15:40:09 -0500 Subject: [PATCH 2/6] feat(debugger): introduce lazy trampoline for first call --- ddtrace/debugging/_origin/span.py | 4 +- ddtrace/internal/wrapping/context.py | 291 +++++++++++++++++++++------ 2 files changed, 229 insertions(+), 66 deletions(-) diff --git a/ddtrace/debugging/_origin/span.py b/ddtrace/debugging/_origin/span.py index ddbbe66842b..d22c1fa3c52 100644 --- a/ddtrace/debugging/_origin/span.py +++ b/ddtrace/debugging/_origin/span.py @@ -223,8 +223,8 @@ def instrument_view(cls, f): _f = t.cast(FunctionType, f) if not EntrySpanWrappingContext.is_wrapped(_f): - log.debug("Patching entrypoint %r for code origin", f) - EntrySpanWrappingContext(cls.__uploader__, _f).wrap() + log.debug("Lazy wrapping entrypoint %r for code origin", f) + EntrySpanWrappingContext(cls.__uploader__, _f).wrap_lazy() @classmethod def enable(cls): diff --git a/ddtrace/internal/wrapping/context.py b/ddtrace/internal/wrapping/context.py index 2389631106a..6cb4b7ca83e 100644 --- a/ddtrace/internal/wrapping/context.py +++ b/ddtrace/internal/wrapping/context.py @@ -3,6 +3,7 @@ from inspect import iscoroutinefunction from inspect import isgeneratorfunction import sys +from types import CodeType from types import FrameType from types import FunctionType from types import TracebackType @@ -24,6 +25,117 @@ T = t.TypeVar("T") +# ============================================================================ +# Lazy wrapping machinery: allows deferring expensive bytecode instrumentation +# until the first time a function is actually called. +# ============================================================================ + +_lazy_registry: t.Dict[CodeType, "LazyMeta"] = {} + + +class LazyMeta: + """Metadata for a lazily-wrapped function.""" + + __slots__ = ("func", "builder", "lock", "initialized") + + def __init__(self, func: FunctionType, builder: t.Callable[[FunctionType], CodeType]): + self.func = func # the original function object + self.builder = builder # callable: builder(func) -> CodeType + self.initialized = False + + +def __lazy_trampoline_entry(*args, **kwargs): + """ + Called from the trampoline code on first invocation. + Uses the current frame's code object to find the right LazyMeta, + builds the heavy bytecode, and swaps it in place. + """ + # Get the caller's frame (the trampoline frame) + code = sys._getframe(1).f_code + meta = _lazy_registry[code] + + if not meta.initialized: + if not meta.initialized: + # Build heavy code from the original pre-wrapped function + new_code = meta.builder(meta.func) + # Swap the code on the SAME function object frameworks already hold + meta.func.__code__ = new_code + meta.initialized = True + # Drop the registry entry to free memory + _lazy_registry.pop(code, None) + + # Now call the function again; this time it runs the heavy code + return meta.func(*args, **kwargs) + + +def _make_trampoline_code(template_code: CodeType) -> CodeType: + """ + Build a tiny code object that: + - takes *args, **kwargs + - calls the global __lazy_trampoline_entry(*args, **kwargs) + - returns its value + + Note: This only works for functions with 0 freevars. + """ + bc = Bytecode() + bc.name = template_code.co_name + bc.filename = template_code.co_filename + bc.first_lineno = template_code.co_firstlineno + + # Function signature: accepts *args, **kwargs + bc.argcount = 0 + bc.posonlyargcount = 0 + bc.kwonlyargcount = 0 + bc.flags = bytecode.CompilerFlags.VARARGS | bytecode.CompilerFlags.VARKEYWORDS + + # Declare locals for *args, **kwargs + bc.argnames = ["args", "kwargs"] + + # No freevars or cellvars + bc.freevars = [] + bc.cellvars = [] + + # Call the global entry: __lazy_trampoline_entry(*args, **kwargs) + if sys.version_info >= (3, 13): + # Python 3.13+ + bc.extend( + [ + bytecode.Instr("LOAD_GLOBAL", (True, "__lazy_trampoline_entry")), # (True = NULL + func) + bytecode.Instr("LOAD_FAST", "args"), + bytecode.Instr("BUILD_MAP", 0), + bytecode.Instr("LOAD_FAST", "kwargs"), + bytecode.Instr("DICT_MERGE", 1), + bytecode.Instr("CALL_FUNCTION_EX", 1), + bytecode.Instr("RETURN_VALUE"), + ] + ) + elif sys.version_info >= (3, 11): + # Python 3.11-3.12 + bc.extend( + [ + bytecode.Instr("PUSH_NULL"), + bytecode.Instr("LOAD_GLOBAL", (False, "__lazy_trampoline_entry")), + bytecode.Instr("LOAD_FAST", "args"), + bytecode.Instr("LOAD_FAST", "kwargs"), + bytecode.Instr("CALL_FUNCTION_EX", 1), + bytecode.Instr("RETURN_VALUE"), + ] + ) + else: + # Python 3.10 and earlier + bc.extend( + [ + bytecode.Instr("LOAD_GLOBAL", "__lazy_trampoline_entry"), + bytecode.Instr("LOAD_FAST", "args"), + bytecode.Instr("LOAD_FAST", "kwargs"), + bytecode.Instr("CALL_FUNCTION_EX", 1), + bytecode.Instr("RETURN_VALUE"), + ] + ) + + return bc.to_code() + + # This module implements utilities for wrapping a function with a context # manager. The rough idea is to re-write the function's bytecode to look like # this: @@ -405,6 +517,10 @@ def extract(cls, f: FunctionType) -> "WrappingContext": def wrap(self) -> None: t.cast(_UniversalWrappingContext, _UniversalWrappingContext.wrapped(self.__wrapped__)).register(self) + def wrap_lazy(self) -> None: + """Install lazy wrapping that defers bytecode instrumentation until first call.""" + t.cast(_UniversalWrappingContext, _UniversalWrappingContext.wrapped_lazy(self.__wrapped__)).register(self) + def unwrap(self) -> None: f = self.__wrapped__ @@ -475,6 +591,16 @@ def register(self, context: WrappingContext) -> None: self._contexts.append(context) self._contexts.sort(key=lambda c: c.__priority__) + @classmethod + def wrapped_lazy(cls, f: FunctionType) -> "_UniversalWrappingContext": + """Create a universal wrapping context with lazy bytecode instrumentation.""" + if cls.is_wrapped(f): + context = cls.extract(f) + else: + context = cls(f) + context.wrap_lazy() + return context + def unregister(self, context: WrappingContext) -> None: try: self._contexts.remove(context) @@ -541,86 +667,123 @@ def extract(cls, f: FunctionType) -> "_UniversalWrappingContext": raise ValueError("Function is not wrapped") return t.cast(_UniversalWrappingContext, t.cast(ContextWrappedFunction, f).__dd_context_wrapped__) - if sys.version_info >= (3, 11): - - def wrap(self) -> None: - f = self.__wrapped__ + def _build_wrapped_code(self, f: FunctionType) -> CodeType: + """ + Build the heavy instrumented bytecode for a function. + This is extracted from wrap() to enable lazy wrapping. + """ + bc = Bytecode.from_code(f.__code__) - if self.is_wrapped(f): - raise ValueError("Function already wrapped") + # Prefix every return + i = 0 + while i < len(bc): + instr = bc[i] + try: + if instr.name == "RETURN_VALUE": + return_code = CONTEXT_RETURN.bind({"context_return": self.__return__}, lineno=instr.lineno) + elif sys.version_info >= (3, 12) and instr.name == "RETURN_CONST": # Python 3.12+ + return_code = CONTEXT_RETURN_CONST.bind( + {"context_return": self.__return__, "value": instr.arg}, lineno=instr.lineno + ) + else: + return_code = [] - bc = Bytecode.from_code(f.__code__) + bc[i:i] = return_code + i += len(return_code) + except AttributeError: + # Not an instruction + pass + i += 1 - # Prefix every return + # Search for the RESUME instruction + for i, instr in enumerate(bc, 1): + try: + if instr.name == "RESUME": + break + except AttributeError: + # Not an instruction + pass + else: i = 0 - while i < len(bc): - instr = bc[i] - try: - if instr.name == "RETURN_VALUE": - return_code = CONTEXT_RETURN.bind({"context_return": self.__return__}, lineno=instr.lineno) - elif sys.version_info >= (3, 12) and instr.name == "RETURN_CONST": # Python 3.12+ - return_code = CONTEXT_RETURN_CONST.bind( - {"context_return": self.__return__, "value": instr.arg}, lineno=instr.lineno - ) - else: - return_code = [] - bc[i:i] = return_code - i += len(return_code) - except AttributeError: - # Not an instruction - pass - i += 1 + bc[i:i] = CONTEXT_HEAD.bind({"context_enter": self.__enter__}, lineno=f.__code__.co_firstlineno) - # Search for the RESUME instruction - for i, instr in enumerate(bc, 1): - try: - if instr.name == "RESUME": + # Wrap every line outside a try block + except_label = bytecode.Label() + first_try_begin = last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) + + i = 0 + while i < len(bc): + instr = bc[i] + if isinstance(instr, bytecode.TryBegin) and last_try_begin is not None: + bc.insert(i, bytecode.TryEnd(last_try_begin)) + last_try_begin = None + i += 1 + elif isinstance(instr, bytecode.TryEnd): + j = i + 1 + while j < len(bc) and not isinstance(bc[j], bytecode.TryBegin): + if isinstance(bc[j], bytecode.Instr): + last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) + bc.insert(i + 1, last_try_begin) break - except AttributeError: - # Not an instruction - pass - else: - i = 0 + j += 1 + i += 1 + i += 1 - bc[i:i] = CONTEXT_HEAD.bind({"context_enter": self.__enter__}, lineno=f.__code__.co_firstlineno) + bc.insert(0, first_try_begin) - # Wrap every line outside a try block - except_label = bytecode.Label() - first_try_begin = last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) + bc.append(bytecode.TryEnd(last_try_begin)) + bc.append(except_label) + bc.extend(CONTEXT_FOOT.bind({"context_exit": self._exit})) - i = 0 - while i < len(bc): - instr = bc[i] - if isinstance(instr, bytecode.TryBegin) and last_try_begin is not None: - bc.insert(i, bytecode.TryEnd(last_try_begin)) - last_try_begin = None - i += 1 - elif isinstance(instr, bytecode.TryEnd): - j = i + 1 - while j < len(bc) and not isinstance(bc[j], bytecode.TryBegin): - if isinstance(bc[j], bytecode.Instr): - last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) - bc.insert(i + 1, last_try_begin) - break - j += 1 - i += 1 - i += 1 + # Link the function to its original code object so that we can retrieve + # it later if required. + link_function_to_code(f.__code__, f) - bc.insert(0, first_try_begin) + return bc.to_code() - bc.append(bytecode.TryEnd(last_try_begin)) - bc.append(except_label) - bc.extend(CONTEXT_FOOT.bind({"context_exit": self._exit})) + def wrap_lazy(self) -> None: + """ + Install lazy wrapping: replace function's __code__ with a trampoline + that defers the expensive bytecode instrumentation until first call. + """ + f = self.__wrapped__ + + if self.is_wrapped(f): + raise ValueError("Function already wrapped") + + # Lazy wrapping doesn't work with closures (functions with freevars) + # because we can't replace __code__ with different freevar counts. + # Fall back to eager wrapping in this case. + if len(f.__code__.co_freevars) > 0: + return self.wrap() + + # Mark the function as wrapped immediately (before the trampoline) + t.cast(ContextWrappedFunction, f).__dd_context_wrapped__ = self + + # Create a trampoline code object with matching freevars + tramp_code = _make_trampoline_code(f.__code__) + + # Register the builder that will be called on first invocation + _lazy_registry[tramp_code] = LazyMeta(f, self._build_wrapped_code) + + # Swap in the trampoline (cheap operation) + f.__code__ = tramp_code + + if sys.version_info >= (3, 11): + + def wrap(self) -> None: + """Eagerly wrap the function with full bytecode instrumentation.""" + f = self.__wrapped__ + + if self.is_wrapped(f): + raise ValueError("Function already wrapped") # Mark the function as wrapped by a wrapping context t.cast(ContextWrappedFunction, f).__dd_context_wrapped__ = self - # Replace the function code with the wrapped code. We also link - # the function to its original code object so that we can retrieve - # it later if required. - link_function_to_code(f.__code__, f) - f.__code__ = bc.to_code() + # Build and install the heavy wrapped code immediately + f.__code__ = self._build_wrapped_code(f) def unwrap(self) -> None: f = self.__wrapped__ From fc8800155dee79b0e07518f5c110e6b826ddd1ac Mon Sep 17 00:00:00 2001 From: "Gabriele N. Tornetta" Date: Wed, 19 Nov 2025 16:33:50 +0000 Subject: [PATCH 3/6] use new lazy wrapping context --- ddtrace/debugging/_origin/span.py | 13 +- ddtrace/internal/wrapping/context.py | 291 ++++++--------------------- tests/debugging/origin/test_span.py | 3 +- 3 files changed, 75 insertions(+), 232 deletions(-) diff --git a/ddtrace/debugging/_origin/span.py b/ddtrace/debugging/_origin/span.py index d22c1fa3c52..bff4e658b90 100644 --- a/ddtrace/debugging/_origin/span.py +++ b/ddtrace/debugging/_origin/span.py @@ -26,7 +26,7 @@ from ddtrace.internal.packages import is_user_code from ddtrace.internal.safety import _isinstance from ddtrace.internal.settings.code_origin import config as co_config -from ddtrace.internal.wrapping.context import WrappingContext +from ddtrace.internal.wrapping.context import LazyWrappingContext from ddtrace.trace import Span @@ -110,7 +110,14 @@ class EntrySpanLocation: probe: EntrySpanProbe -class EntrySpanWrappingContext(WrappingContext): +class EntrySpanWrappingContext(LazyWrappingContext): + """Entry span wrapping context. + + This context is lazy to avoid paid any upfront instrumentation costs for + large functions that might not get invoked. Instead, the actual wrapping + will be performed on the first invocation. + """ + __enabled__ = False __priority__ = 199 @@ -224,7 +231,7 @@ def instrument_view(cls, f): _f = t.cast(FunctionType, f) if not EntrySpanWrappingContext.is_wrapped(_f): log.debug("Lazy wrapping entrypoint %r for code origin", f) - EntrySpanWrappingContext(cls.__uploader__, _f).wrap_lazy() + EntrySpanWrappingContext(cls.__uploader__, _f).wrap() @classmethod def enable(cls): diff --git a/ddtrace/internal/wrapping/context.py b/ddtrace/internal/wrapping/context.py index 6cb4b7ca83e..2389631106a 100644 --- a/ddtrace/internal/wrapping/context.py +++ b/ddtrace/internal/wrapping/context.py @@ -3,7 +3,6 @@ from inspect import iscoroutinefunction from inspect import isgeneratorfunction import sys -from types import CodeType from types import FrameType from types import FunctionType from types import TracebackType @@ -25,117 +24,6 @@ T = t.TypeVar("T") -# ============================================================================ -# Lazy wrapping machinery: allows deferring expensive bytecode instrumentation -# until the first time a function is actually called. -# ============================================================================ - -_lazy_registry: t.Dict[CodeType, "LazyMeta"] = {} - - -class LazyMeta: - """Metadata for a lazily-wrapped function.""" - - __slots__ = ("func", "builder", "lock", "initialized") - - def __init__(self, func: FunctionType, builder: t.Callable[[FunctionType], CodeType]): - self.func = func # the original function object - self.builder = builder # callable: builder(func) -> CodeType - self.initialized = False - - -def __lazy_trampoline_entry(*args, **kwargs): - """ - Called from the trampoline code on first invocation. - Uses the current frame's code object to find the right LazyMeta, - builds the heavy bytecode, and swaps it in place. - """ - # Get the caller's frame (the trampoline frame) - code = sys._getframe(1).f_code - meta = _lazy_registry[code] - - if not meta.initialized: - if not meta.initialized: - # Build heavy code from the original pre-wrapped function - new_code = meta.builder(meta.func) - # Swap the code on the SAME function object frameworks already hold - meta.func.__code__ = new_code - meta.initialized = True - # Drop the registry entry to free memory - _lazy_registry.pop(code, None) - - # Now call the function again; this time it runs the heavy code - return meta.func(*args, **kwargs) - - -def _make_trampoline_code(template_code: CodeType) -> CodeType: - """ - Build a tiny code object that: - - takes *args, **kwargs - - calls the global __lazy_trampoline_entry(*args, **kwargs) - - returns its value - - Note: This only works for functions with 0 freevars. - """ - bc = Bytecode() - bc.name = template_code.co_name - bc.filename = template_code.co_filename - bc.first_lineno = template_code.co_firstlineno - - # Function signature: accepts *args, **kwargs - bc.argcount = 0 - bc.posonlyargcount = 0 - bc.kwonlyargcount = 0 - bc.flags = bytecode.CompilerFlags.VARARGS | bytecode.CompilerFlags.VARKEYWORDS - - # Declare locals for *args, **kwargs - bc.argnames = ["args", "kwargs"] - - # No freevars or cellvars - bc.freevars = [] - bc.cellvars = [] - - # Call the global entry: __lazy_trampoline_entry(*args, **kwargs) - if sys.version_info >= (3, 13): - # Python 3.13+ - bc.extend( - [ - bytecode.Instr("LOAD_GLOBAL", (True, "__lazy_trampoline_entry")), # (True = NULL + func) - bytecode.Instr("LOAD_FAST", "args"), - bytecode.Instr("BUILD_MAP", 0), - bytecode.Instr("LOAD_FAST", "kwargs"), - bytecode.Instr("DICT_MERGE", 1), - bytecode.Instr("CALL_FUNCTION_EX", 1), - bytecode.Instr("RETURN_VALUE"), - ] - ) - elif sys.version_info >= (3, 11): - # Python 3.11-3.12 - bc.extend( - [ - bytecode.Instr("PUSH_NULL"), - bytecode.Instr("LOAD_GLOBAL", (False, "__lazy_trampoline_entry")), - bytecode.Instr("LOAD_FAST", "args"), - bytecode.Instr("LOAD_FAST", "kwargs"), - bytecode.Instr("CALL_FUNCTION_EX", 1), - bytecode.Instr("RETURN_VALUE"), - ] - ) - else: - # Python 3.10 and earlier - bc.extend( - [ - bytecode.Instr("LOAD_GLOBAL", "__lazy_trampoline_entry"), - bytecode.Instr("LOAD_FAST", "args"), - bytecode.Instr("LOAD_FAST", "kwargs"), - bytecode.Instr("CALL_FUNCTION_EX", 1), - bytecode.Instr("RETURN_VALUE"), - ] - ) - - return bc.to_code() - - # This module implements utilities for wrapping a function with a context # manager. The rough idea is to re-write the function's bytecode to look like # this: @@ -517,10 +405,6 @@ def extract(cls, f: FunctionType) -> "WrappingContext": def wrap(self) -> None: t.cast(_UniversalWrappingContext, _UniversalWrappingContext.wrapped(self.__wrapped__)).register(self) - def wrap_lazy(self) -> None: - """Install lazy wrapping that defers bytecode instrumentation until first call.""" - t.cast(_UniversalWrappingContext, _UniversalWrappingContext.wrapped_lazy(self.__wrapped__)).register(self) - def unwrap(self) -> None: f = self.__wrapped__ @@ -591,16 +475,6 @@ def register(self, context: WrappingContext) -> None: self._contexts.append(context) self._contexts.sort(key=lambda c: c.__priority__) - @classmethod - def wrapped_lazy(cls, f: FunctionType) -> "_UniversalWrappingContext": - """Create a universal wrapping context with lazy bytecode instrumentation.""" - if cls.is_wrapped(f): - context = cls.extract(f) - else: - context = cls(f) - context.wrap_lazy() - return context - def unregister(self, context: WrappingContext) -> None: try: self._contexts.remove(context) @@ -667,123 +541,86 @@ def extract(cls, f: FunctionType) -> "_UniversalWrappingContext": raise ValueError("Function is not wrapped") return t.cast(_UniversalWrappingContext, t.cast(ContextWrappedFunction, f).__dd_context_wrapped__) - def _build_wrapped_code(self, f: FunctionType) -> CodeType: - """ - Build the heavy instrumented bytecode for a function. - This is extracted from wrap() to enable lazy wrapping. - """ - bc = Bytecode.from_code(f.__code__) - - # Prefix every return - i = 0 - while i < len(bc): - instr = bc[i] - try: - if instr.name == "RETURN_VALUE": - return_code = CONTEXT_RETURN.bind({"context_return": self.__return__}, lineno=instr.lineno) - elif sys.version_info >= (3, 12) and instr.name == "RETURN_CONST": # Python 3.12+ - return_code = CONTEXT_RETURN_CONST.bind( - {"context_return": self.__return__, "value": instr.arg}, lineno=instr.lineno - ) - else: - return_code = [] + if sys.version_info >= (3, 11): - bc[i:i] = return_code - i += len(return_code) - except AttributeError: - # Not an instruction - pass - i += 1 + def wrap(self) -> None: + f = self.__wrapped__ - # Search for the RESUME instruction - for i, instr in enumerate(bc, 1): - try: - if instr.name == "RESUME": - break - except AttributeError: - # Not an instruction - pass - else: - i = 0 + if self.is_wrapped(f): + raise ValueError("Function already wrapped") - bc[i:i] = CONTEXT_HEAD.bind({"context_enter": self.__enter__}, lineno=f.__code__.co_firstlineno) + bc = Bytecode.from_code(f.__code__) - # Wrap every line outside a try block - except_label = bytecode.Label() - first_try_begin = last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) + # Prefix every return + i = 0 + while i < len(bc): + instr = bc[i] + try: + if instr.name == "RETURN_VALUE": + return_code = CONTEXT_RETURN.bind({"context_return": self.__return__}, lineno=instr.lineno) + elif sys.version_info >= (3, 12) and instr.name == "RETURN_CONST": # Python 3.12+ + return_code = CONTEXT_RETURN_CONST.bind( + {"context_return": self.__return__, "value": instr.arg}, lineno=instr.lineno + ) + else: + return_code = [] - i = 0 - while i < len(bc): - instr = bc[i] - if isinstance(instr, bytecode.TryBegin) and last_try_begin is not None: - bc.insert(i, bytecode.TryEnd(last_try_begin)) - last_try_begin = None - i += 1 - elif isinstance(instr, bytecode.TryEnd): - j = i + 1 - while j < len(bc) and not isinstance(bc[j], bytecode.TryBegin): - if isinstance(bc[j], bytecode.Instr): - last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) - bc.insert(i + 1, last_try_begin) - break - j += 1 + bc[i:i] = return_code + i += len(return_code) + except AttributeError: + # Not an instruction + pass i += 1 - i += 1 - - bc.insert(0, first_try_begin) - - bc.append(bytecode.TryEnd(last_try_begin)) - bc.append(except_label) - bc.extend(CONTEXT_FOOT.bind({"context_exit": self._exit})) - - # Link the function to its original code object so that we can retrieve - # it later if required. - link_function_to_code(f.__code__, f) - - return bc.to_code() - - def wrap_lazy(self) -> None: - """ - Install lazy wrapping: replace function's __code__ with a trampoline - that defers the expensive bytecode instrumentation until first call. - """ - f = self.__wrapped__ - - if self.is_wrapped(f): - raise ValueError("Function already wrapped") - # Lazy wrapping doesn't work with closures (functions with freevars) - # because we can't replace __code__ with different freevar counts. - # Fall back to eager wrapping in this case. - if len(f.__code__.co_freevars) > 0: - return self.wrap() - - # Mark the function as wrapped immediately (before the trampoline) - t.cast(ContextWrappedFunction, f).__dd_context_wrapped__ = self - - # Create a trampoline code object with matching freevars - tramp_code = _make_trampoline_code(f.__code__) + # Search for the RESUME instruction + for i, instr in enumerate(bc, 1): + try: + if instr.name == "RESUME": + break + except AttributeError: + # Not an instruction + pass + else: + i = 0 - # Register the builder that will be called on first invocation - _lazy_registry[tramp_code] = LazyMeta(f, self._build_wrapped_code) + bc[i:i] = CONTEXT_HEAD.bind({"context_enter": self.__enter__}, lineno=f.__code__.co_firstlineno) - # Swap in the trampoline (cheap operation) - f.__code__ = tramp_code + # Wrap every line outside a try block + except_label = bytecode.Label() + first_try_begin = last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) - if sys.version_info >= (3, 11): + i = 0 + while i < len(bc): + instr = bc[i] + if isinstance(instr, bytecode.TryBegin) and last_try_begin is not None: + bc.insert(i, bytecode.TryEnd(last_try_begin)) + last_try_begin = None + i += 1 + elif isinstance(instr, bytecode.TryEnd): + j = i + 1 + while j < len(bc) and not isinstance(bc[j], bytecode.TryBegin): + if isinstance(bc[j], bytecode.Instr): + last_try_begin = bytecode.TryBegin(except_label, push_lasti=True) + bc.insert(i + 1, last_try_begin) + break + j += 1 + i += 1 + i += 1 - def wrap(self) -> None: - """Eagerly wrap the function with full bytecode instrumentation.""" - f = self.__wrapped__ + bc.insert(0, first_try_begin) - if self.is_wrapped(f): - raise ValueError("Function already wrapped") + bc.append(bytecode.TryEnd(last_try_begin)) + bc.append(except_label) + bc.extend(CONTEXT_FOOT.bind({"context_exit": self._exit})) # Mark the function as wrapped by a wrapping context t.cast(ContextWrappedFunction, f).__dd_context_wrapped__ = self - # Build and install the heavy wrapped code immediately - f.__code__ = self._build_wrapped_code(f) + # Replace the function code with the wrapped code. We also link + # the function to its original code object so that we can retrieve + # it later if required. + link_function_to_code(f.__code__, f) + f.__code__ = bc.to_code() def unwrap(self) -> None: f = self.__wrapped__ diff --git a/tests/debugging/origin/test_span.py b/tests/debugging/origin/test_span.py index 8bebba8d583..d416c35f7a5 100644 --- a/tests/debugging/origin/test_span.py +++ b/tests/debugging/origin/test_span.py @@ -1,6 +1,5 @@ from functools import partial from pathlib import Path -import time import typing as t import pytest @@ -186,7 +185,7 @@ def setup(): def realistic_view(request_arg, *args, **kwargs): """A realistic view function with actual code.""" import json - import os + import os # noqa data = {"status": "ok", "items": []} for i in range(10): From 5b947cd5aafa81550d2418bb20e644ee0fa9c6be Mon Sep 17 00:00:00 2001 From: "Gabriele N. Tornetta" Date: Wed, 19 Nov 2025 17:37:06 +0000 Subject: [PATCH 4/6] update requirements --- .riot/requirements/1353fd0.txt | 22 --------------- .riot/requirements/1540a76.txt | 22 --------------- .riot/requirements/18417b5.txt | 28 +++++++++++++++++++ .riot/requirements/18569c9.txt | 25 +++++++++++++++++ .riot/requirements/191f37c.txt | 28 +++++++++++++++++++ .riot/requirements/197e9b6.txt | 25 +++++++++++++++++ .../requirements/{1d9ed4a.txt => 1a30fec.txt} | 20 +++++++------ .riot/requirements/1a9b995.txt | 24 ---------------- .riot/requirements/2644218.txt | 22 --------------- .riot/requirements/278c26c.txt | 25 +++++++++++++++++ .riot/requirements/5c95c1a.txt | 24 ---------------- 11 files changed, 142 insertions(+), 123 deletions(-) delete mode 100644 .riot/requirements/1353fd0.txt delete mode 100644 .riot/requirements/1540a76.txt create mode 100644 .riot/requirements/18417b5.txt create mode 100644 .riot/requirements/18569c9.txt create mode 100644 .riot/requirements/191f37c.txt create mode 100644 .riot/requirements/197e9b6.txt rename .riot/requirements/{1d9ed4a.txt => 1a30fec.txt} (62%) delete mode 100644 .riot/requirements/1a9b995.txt delete mode 100644 .riot/requirements/2644218.txt create mode 100644 .riot/requirements/278c26c.txt delete mode 100644 .riot/requirements/5c95c1a.txt diff --git a/.riot/requirements/1353fd0.txt b/.riot/requirements/1353fd0.txt deleted file mode 100644 index d8b18bdb4ef..00000000000 --- a/.riot/requirements/1353fd0.txt +++ /dev/null @@ -1,22 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --no-annotate .riot/requirements/1353fd0.in -# -attrs==23.1.0 -coverage[toml]==7.3.0 -httpretty==1.1.4 -hypothesis==6.45.0 -iniconfig==2.0.0 -mock==5.1.0 -msgpack==1.0.5 -opentracing==2.4.0 -packaging==23.1 -pluggy==1.2.0 -pytest==7.4.0 -pytest-asyncio==0.21.1 -pytest-cov==4.1.0 -pytest-mock==3.11.1 -sortedcontainers==2.4.0 -typing-extensions==4.7.1 diff --git a/.riot/requirements/1540a76.txt b/.riot/requirements/1540a76.txt deleted file mode 100644 index 077ec4693fe..00000000000 --- a/.riot/requirements/1540a76.txt +++ /dev/null @@ -1,22 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --no-annotate .riot/requirements/1540a76.in -# -attrs==23.1.0 -coverage[toml]==7.3.1 -httpretty==1.1.4 -hypothesis==6.45.0 -iniconfig==2.0.0 -mock==5.1.0 -msgpack==1.0.5 -opentracing==2.4.0 -packaging==23.1 -pluggy==1.3.0 -pytest==7.4.2 -pytest-asyncio==0.21.1 -pytest-cov==4.1.0 -pytest-mock==3.11.1 -sortedcontainers==2.4.0 -typing-extensions==4.7.1 diff --git a/.riot/requirements/18417b5.txt b/.riot/requirements/18417b5.txt new file mode 100644 index 00000000000..46520d553cf --- /dev/null +++ b/.riot/requirements/18417b5.txt @@ -0,0 +1,28 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/18417b5.in +# +attrs==25.4.0 +backports-asyncio-runner==1.2.0 +coverage[toml]==7.12.0 +exceptiongroup==1.3.0 +httpretty==1.1.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +msgpack==1.1.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +py-cpuinfo==9.0.0 +pygments==2.19.2 +pytest==9.0.1 +pytest-asyncio==1.3.0 +pytest-benchmark==5.2.3 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +sortedcontainers==2.4.0 +tomli==2.3.0 +typing-extensions==4.15.0 diff --git a/.riot/requirements/18569c9.txt b/.riot/requirements/18569c9.txt new file mode 100644 index 00000000000..7334e88ad6e --- /dev/null +++ b/.riot/requirements/18569c9.txt @@ -0,0 +1,25 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/18569c9.in +# +attrs==25.4.0 +coverage[toml]==7.12.0 +httpretty==1.1.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +msgpack==1.1.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +py-cpuinfo==9.0.0 +pygments==2.19.2 +pytest==9.0.1 +pytest-asyncio==1.3.0 +pytest-benchmark==5.2.3 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +sortedcontainers==2.4.0 +typing-extensions==4.15.0 diff --git a/.riot/requirements/191f37c.txt b/.riot/requirements/191f37c.txt new file mode 100644 index 00000000000..10daf78f202 --- /dev/null +++ b/.riot/requirements/191f37c.txt @@ -0,0 +1,28 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/191f37c.in +# +attrs==25.4.0 +backports-asyncio-runner==1.2.0 +coverage[toml]==7.10.7 +exceptiongroup==1.3.0 +httpretty==1.1.4 +hypothesis==6.45.0 +iniconfig==2.1.0 +mock==5.2.0 +msgpack==1.1.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +py-cpuinfo==9.0.0 +pygments==2.19.2 +pytest==8.4.2 +pytest-asyncio==1.2.0 +pytest-benchmark==5.2.3 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +sortedcontainers==2.4.0 +tomli==2.3.0 +typing-extensions==4.15.0 diff --git a/.riot/requirements/197e9b6.txt b/.riot/requirements/197e9b6.txt new file mode 100644 index 00000000000..3356b69f9fc --- /dev/null +++ b/.riot/requirements/197e9b6.txt @@ -0,0 +1,25 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/197e9b6.in +# +attrs==25.4.0 +coverage[toml]==7.12.0 +httpretty==1.1.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +msgpack==1.1.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +py-cpuinfo==9.0.0 +pygments==2.19.2 +pytest==9.0.1 +pytest-asyncio==1.3.0 +pytest-benchmark==5.2.3 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +sortedcontainers==2.4.0 +typing-extensions==4.15.0 diff --git a/.riot/requirements/1d9ed4a.txt b/.riot/requirements/1a30fec.txt similarity index 62% rename from .riot/requirements/1d9ed4a.txt rename to .riot/requirements/1a30fec.txt index 89153cb1f60..cf20f6e1087 100644 --- a/.riot/requirements/1d9ed4a.txt +++ b/.riot/requirements/1a30fec.txt @@ -2,22 +2,24 @@ # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --allow-unsafe --no-annotate .riot/requirements/1d9ed4a.in +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1a30fec.in # -attrs==25.3.0 -coverage[toml]==7.10.5 +attrs==25.4.0 +coverage[toml]==7.12.0 httpretty==1.1.4 hypothesis==6.45.0 -iniconfig==2.1.0 +iniconfig==2.3.0 mock==5.2.0 -msgpack==1.1.1 +msgpack==1.1.2 opentracing==2.4.0 packaging==25.0 pluggy==1.6.0 +py-cpuinfo==9.0.0 pygments==2.19.2 -pytest==8.4.1 -pytest-asyncio==1.1.0 -pytest-cov==6.2.1 -pytest-mock==3.14.1 +pytest==9.0.1 +pytest-asyncio==1.3.0 +pytest-benchmark==5.2.3 +pytest-cov==7.0.0 +pytest-mock==3.15.1 sortedcontainers==2.4.0 typing-extensions==4.15.0 diff --git a/.riot/requirements/1a9b995.txt b/.riot/requirements/1a9b995.txt deleted file mode 100644 index 0eba8f41fa6..00000000000 --- a/.riot/requirements/1a9b995.txt +++ /dev/null @@ -1,24 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.10 -# by the following command: -# -# pip-compile --no-annotate .riot/requirements/1a9b995.in -# -attrs==23.1.0 -coverage[toml]==7.3.0 -exceptiongroup==1.1.3 -httpretty==1.1.4 -hypothesis==6.45.0 -iniconfig==2.0.0 -mock==5.1.0 -msgpack==1.0.5 -opentracing==2.4.0 -packaging==23.1 -pluggy==1.2.0 -pytest==7.4.0 -pytest-asyncio==0.21.1 -pytest-cov==4.1.0 -pytest-mock==3.11.1 -sortedcontainers==2.4.0 -tomli==2.0.1 -typing-extensions==4.7.1 diff --git a/.riot/requirements/2644218.txt b/.riot/requirements/2644218.txt deleted file mode 100644 index 0af7a95877a..00000000000 --- a/.riot/requirements/2644218.txt +++ /dev/null @@ -1,22 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.13 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/2644218.in -# -attrs==24.2.0 -coverage[toml]==7.6.1 -httpretty==1.1.4 -hypothesis==6.45.0 -iniconfig==2.0.0 -mock==5.1.0 -msgpack==1.1.0 -opentracing==2.4.0 -packaging==24.1 -pluggy==1.5.0 -pytest==8.3.3 -pytest-asyncio==0.24.0 -pytest-cov==5.0.0 -pytest-mock==3.14.0 -sortedcontainers==2.4.0 -typing-extensions==4.12.2 diff --git a/.riot/requirements/278c26c.txt b/.riot/requirements/278c26c.txt new file mode 100644 index 00000000000..742caba2a94 --- /dev/null +++ b/.riot/requirements/278c26c.txt @@ -0,0 +1,25 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/278c26c.in +# +attrs==25.4.0 +coverage[toml]==7.12.0 +httpretty==1.1.4 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +msgpack==1.1.2 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +py-cpuinfo==9.0.0 +pygments==2.19.2 +pytest==9.0.1 +pytest-asyncio==1.3.0 +pytest-benchmark==5.2.3 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +sortedcontainers==2.4.0 +typing-extensions==4.15.0 diff --git a/.riot/requirements/5c95c1a.txt b/.riot/requirements/5c95c1a.txt deleted file mode 100644 index 52169e753cf..00000000000 --- a/.riot/requirements/5c95c1a.txt +++ /dev/null @@ -1,24 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.9 -# by the following command: -# -# pip-compile --no-annotate .riot/requirements/5c95c1a.in -# -attrs==23.1.0 -coverage[toml]==7.3.0 -exceptiongroup==1.1.3 -httpretty==1.1.4 -hypothesis==6.45.0 -iniconfig==2.0.0 -mock==5.1.0 -msgpack==1.0.5 -opentracing==2.4.0 -packaging==23.1 -pluggy==1.2.0 -pytest==7.4.0 -pytest-asyncio==0.21.1 -pytest-cov==4.1.0 -pytest-mock==3.11.1 -sortedcontainers==2.4.0 -tomli==2.0.1 -typing-extensions==4.7.1 From 45733c9ae987dc84fbaa2eca10cb7016f96ede2c Mon Sep 17 00:00:00 2001 From: "Gabriele N. Tornetta" Date: Wed, 26 Nov 2025 12:09:07 +0000 Subject: [PATCH 5/6] fix line-lazy wrapping compat --- ddtrace/internal/utils/inspection.py | 3 --- tests/debugging/test_debugger.py | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/ddtrace/internal/utils/inspection.py b/ddtrace/internal/utils/inspection.py index a8a3ca28e82..7c8abf69d6f 100644 --- a/ddtrace/internal/utils/inspection.py +++ b/ddtrace/internal/utils/inspection.py @@ -50,9 +50,6 @@ def undecorated(f: FunctionType, name: str, path: Path) -> FunctionType: def match(g): return g.__code__.co_name == name and resolved_code_origin(g.__code__) == path - if _isinstance(f, FunctionType) and match(f): - return f - seen_functions = {f} q = deque([f]) # FIFO: use popleft and append diff --git a/tests/debugging/test_debugger.py b/tests/debugging/test_debugger.py index 23a8b295be2..63ed69ae10e 100644 --- a/tests/debugging/test_debugger.py +++ b/tests/debugging/test_debugger.py @@ -621,6 +621,27 @@ def test_debugger_line_probe_on_wrapped_function(stuff): assert snapshot.probe.probe_id == "line-probe-wrapped-method" +def test_debugger_line_probe_on_lazy_wrapped_function(stuff): + from ddtrace.internal.wrapping.context import LazyWrappingContext + + LazyWrappingContext(stuff.durationstuff).wrap() + + with debugger() as d: + d.add_probes( + create_snapshot_line_probe( + probe_id="line-probe-lazy-wrapping", + source_file="tests/submod/stuff.py", + line=133, + condition=None, + ) + ) + + stuff.durationstuff(0) + + with d.assert_single_snapshot() as snapshot: + assert snapshot.probe.probe_id == "line-probe-lazy-wrapping" + + def test_probe_status_logging(remote_config_worker, stuff): assert remoteconfig_poller.status == ServiceStatus.STOPPED From 1467c02b4bfd0f9e52f609881ea69492825386a0 Mon Sep 17 00:00:00 2001 From: "Gabriele N. Tornetta" Date: Thu, 27 Nov 2025 11:24:04 +0000 Subject: [PATCH 6/6] fix wrapping context-lazy wrapping context compat --- ddtrace/debugging/_function/discovery.py | 5 ++- ddtrace/internal/wrapping/__init__.py | 7 +++++ ddtrace/internal/wrapping/context.py | 18 +++++++++++ tests/debugging/mocking.py | 2 +- tests/debugging/test_debugger.py | 40 ++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ddtrace/debugging/_function/discovery.py b/ddtrace/debugging/_function/discovery.py index 7fc219f6ef1..8eabc7d0ae2 100644 --- a/ddtrace/debugging/_function/discovery.py +++ b/ddtrace/debugging/_function/discovery.py @@ -139,7 +139,10 @@ def resolve(self) -> FullyNamedFunction: msg = "Cannot resolve pair with no code object" raise ValueError(msg) - if self.function is not None: + # Check that the function we have cached is not a wrapper layer that + # has been unwrapped. In this case we need to resolve the new function + # from the code object. + if (_ := self.function) is not None and _.__name__ != "": return cast(FullyNamedFunction, self.function) code = self.code diff --git a/ddtrace/internal/wrapping/__init__.py b/ddtrace/internal/wrapping/__init__.py index 852c99dc151..505b2fa65f1 100644 --- a/ddtrace/internal/wrapping/__init__.py +++ b/ddtrace/internal/wrapping/__init__.py @@ -322,6 +322,13 @@ def unwrap(wf, wrapper): # current one. f = cast(FunctionType, wf) f.__code__ = inner.__code__ + + # Mark the function as unwrapped via its name. There might be references + # to this function elsewhere and this would signal that the function has + # been unwrapped and that another function object is referencing the + # original code object. + inner.__name__ = "" + try: # Update the link to the next layer. inner_wf = cast(WrappedFunction, inner) diff --git a/ddtrace/internal/wrapping/context.py b/ddtrace/internal/wrapping/context.py index c18070a3843..a09a00bdbdf 100644 --- a/ddtrace/internal/wrapping/context.py +++ b/ddtrace/internal/wrapping/context.py @@ -394,11 +394,29 @@ def wrap(self) -> None: if self._trampoline is not None: return + # If the function is already universally wrapped so it's less expensive + # to do the normal wrapping. + if _UniversalWrappingContext.is_wrapped(self.__wrapped__): + super().wrap() + return + def trampoline(_, args, kwargs): with tl: f = t.cast(WrappedFunction, self.__wrapped__) if is_wrapped_with(self.__wrapped__, trampoline): + # If the wrapped function was instrumented with a + # wrapping context before the first invocation we need + # to carry that over to the original function when we + # remove the trampoline. + try: + inner = f.__dd_wrapped__ + except AttributeError: + inner = None f = unwrap(f, trampoline) + try: + f.__dd_context_wrapped__ = inner.__dd_context_wrapped__ + except AttributeError: + pass super(LazyWrappingContext, self).wrap() return f(*args, **kwargs) diff --git a/tests/debugging/mocking.py b/tests/debugging/mocking.py index 746f9cd2691..043b5358be7 100644 --- a/tests/debugging/mocking.py +++ b/tests/debugging/mocking.py @@ -177,7 +177,7 @@ def assert_single_snapshot(self): assert len(self.test_queue) == 1 - yield self.test_queue[0] + yield self.test_queue.pop(0) @contextmanager diff --git a/tests/debugging/test_debugger.py b/tests/debugging/test_debugger.py index 63ed69ae10e..81e75c5a8fd 100644 --- a/tests/debugging/test_debugger.py +++ b/tests/debugging/test_debugger.py @@ -642,6 +642,46 @@ def test_debugger_line_probe_on_lazy_wrapped_function(stuff): assert snapshot.probe.probe_id == "line-probe-lazy-wrapping" +def test_debugger_function_probe_on_lazy_wrapped_function(stuff): + from ddtrace.internal.wrapping.context import LazyWrappingContext + + class LWC(LazyWrappingContext): + entered = False + + def __enter__(self): + self.entered = True + return super().__enter__() + + (c := LWC(stuff.throwexcstuff)).wrap() + + probe = create_snapshot_function_probe( + probe_id="function-probe-lazy-wrapping", + module="tests.submod.stuff", + func_qname="throwexcstuff", + rate=float("inf"), + ) + + with debugger() as d: + # Test that we can re-instrument the function correctly and that we + # don't accidentally instrument the temporary trampoline instead. + for _ in range(10): + d.add_probes(probe) + + try: + stuff.throwexcstuff() + except Exception: + pass + + d.remove_probes(probe) + + assert c.entered + + c.entered = False + + with d.assert_single_snapshot() as snapshot: + assert snapshot.probe.probe_id == "function-probe-lazy-wrapping" + + def test_probe_status_logging(remote_config_worker, stuff): assert remoteconfig_poller.status == ServiceStatus.STOPPED