Skip to content

opentelemetry-instrumentation-asyncio: to_thread instrumentation does not measure function execution (span/metric duration is always ~0, state is always "finished") #4900

Description

@bourbonkk

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:

  1. 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.
  2. The asyncio.process.duration histogram records the wrapping overhead, not
    the function execution time.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions