Describe your environment
OS: any (reproduced on macOS 15 / Darwin 25.5.0)
Python version: Python 3.12 (applies to all supported versions)
Package version: 0.66b0.dev (current main), present since the instrumentation was introduced
What happened?
AsyncioInstrumentor.trace_to_thread is supposed to wrap the function passed to
asyncio.to_thread() so that its execution is traced and measured. However, it
returns the original function unmodified and records the span and metrics at
wrap time instead of around the actual execution:
def trace_to_thread(self, func: callable):
...
start = default_timer()
...
span = self._tracer.start_span(...) if ... else None
attr = {"type": "to_thread", "name": func_name}
exception = None
try:
attr["state"] = "finished"
return func # <-- returns func without wrapping it
except Exception: # <-- can never fire; `return func` doesn't call func
attr["state"] = "exception"
raise
finally:
self.record_process(start, attr, span, exception) # <-- records immediately
Consequences:
- The
asyncio to_thread-<name> span ends before the function even starts
running in the worker thread, so its duration is always a few microseconds.
- The
asyncio.process.duration histogram records the wrapping overhead, not
the function execution time.
state is always "finished" — exceptions raised by the function in the
worker thread are never observed, so the exception state and
span.record_exception are dead code for to_thread.
The existing tests (tests/test_asyncio_to_thread.py) only assert span names
and metric attributes, not durations or the error path, which is why this was
never caught.
Steps to Reproduce
import asyncio, os, time
os.environ["OTEL_PYTHON_ASYNCIO_TO_THREAD_FUNCTION_NAMES_TO_TRACE"] = "slow_func"
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.instrumentation.asyncio import AsyncioInstrumentor
exporter = InMemorySpanExporter()
tp = TracerProvider()
tp.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(tp)
AsyncioInstrumentor().instrument()
def slow_func():
time.sleep(0.5)
async def main():
await asyncio.to_thread(slow_func)
asyncio.run(main())
span = exporter.get_finished_spans()[0]
print(span.name, (span.end_time - span.start_time) / 1e9)
Expected Result
The span (and the asyncio.process.duration histogram) should cover the
function execution, i.e. a duration of roughly 0.5s:
asyncio to_thread-slow_func 0.5001...
If the function raises, the metric should be recorded with
state = "exception" and the span should record the exception with an ERROR
status.
Actual Result
asyncio to_thread-slow_func 0.000068
The span duration is ~68µs and the histogram records ~35µs. Exceptions from the
function are never reflected in the span or metrics.
Additional context
The fix seems straightforward: instead of returning func directly,
trace_to_thread should return a wrapper (e.g. via functools.wraps) that
starts the timer/span, calls func(*args, **kwargs) inside try/except/finally,
and records the metrics and span around the actual call. That also makes the
existing exception handling reachable. I'm happy to submit a PR if the
direction sounds right.
Would you like to implement a fix?
Yes
Tip
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it. Learn more here.
Describe your environment
OS: any (reproduced on macOS 15 / Darwin 25.5.0)
Python version: Python 3.12 (applies to all supported versions)
Package version: 0.66b0.dev (current main), present since the instrumentation was introduced
What happened?
AsyncioInstrumentor.trace_to_threadis supposed to wrap the function passed toasyncio.to_thread()so that its execution is traced and measured. However, itreturns the original function unmodified and records the span and metrics at
wrap time instead of around the actual execution:
Consequences:
asyncio to_thread-<name>span ends before the function even startsrunning in the worker thread, so its duration is always a few microseconds.
asyncio.process.durationhistogram records the wrapping overhead, notthe function execution time.
stateis always"finished"— exceptions raised by the function in theworker thread are never observed, so the
exceptionstate andspan.record_exceptionare dead code forto_thread.The existing tests (
tests/test_asyncio_to_thread.py) only assert span namesand metric attributes, not durations or the error path, which is why this was
never caught.
Steps to Reproduce
Expected Result
The span (and the
asyncio.process.durationhistogram) should cover thefunction execution, i.e. a duration of roughly 0.5s:
If the function raises, the metric should be recorded with
state = "exception"and the span should record the exception with an ERRORstatus.
Actual Result
The span duration is ~68µs and the histogram records ~35µs. Exceptions from the
function are never reflected in the span or metrics.
Additional context
The fix seems straightforward: instead of returning
funcdirectly,trace_to_threadshould return a wrapper (e.g. viafunctools.wraps) thatstarts the timer/span, calls
func(*args, **kwargs)inside try/except/finally,and records the metrics and span around the actual call. That also makes the
existing
exceptionhandling reachable. I'm happy to submit a PR if thedirection sounds right.
Would you like to implement a fix?
Yes
Tip
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding
+1orme too, to help us triage it. Learn more here.