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 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/debugging/_origin/span.py b/ddtrace/debugging/_origin/span.py index ac2ef382e77..05f80199369 100644 --- a/ddtrace/debugging/_origin/span.py +++ b/ddtrace/debugging/_origin/span.py @@ -27,7 +27,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 @@ -111,7 +111,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 @@ -227,7 +234,7 @@ def instrument_view(cls, f: t.Union[FunctionType, MethodType]) -> None: _f = t.cast(FunctionType, f) if not EntrySpanWrappingContext.is_wrapped(_f): - log.debug("Patching entrypoint %r for code origin", f) + log.debug("Lazy wrapping entrypoint %r for code origin", f) EntrySpanWrappingContext(cls.__uploader__, _f).wrap() @classmethod 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/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/riotfile.py b/riotfile.py index eecb5c8537b..13fc607e83d 100644 --- a/riotfile.py +++ b/riotfile.py @@ -660,6 +660,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/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/origin/test_span.py b/tests/debugging/origin/test_span.py index 92831f528e9..1dbbc95c593 100644 --- a/tests/debugging/origin/test_span.py +++ b/tests/debugging/origin/test_span.py @@ -197,3 +197,44 @@ def entry_call(self): entry.get_tag("_dd.code_origin.frames.0.method") == "SpanProbeTestCase.test_span_origin_entry_method..App.entry_call" ) + + +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 # noqa + + 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() diff --git a/tests/debugging/test_debugger.py b/tests/debugging/test_debugger.py index 23a8b295be2..81e75c5a8fd 100644 --- a/tests/debugging/test_debugger.py +++ b/tests/debugging/test_debugger.py @@ -621,6 +621,67 @@ 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_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