aiotrace 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .pytest_cache/
5
+ dist/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
9
+ .coverage
10
+ coverage/
11
+ htmlcov/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .pyright/
aiotrace-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 aiotrace contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiotrace
3
+ Version: 0.1.0
4
+ Summary: OpenTelemetry-native async context propagation for asyncio
5
+ Author: aiotrace contributors
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: asyncio,contextvars,observability,opentelemetry,tracing
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: System :: Monitoring
19
+ Requires-Dist: opentelemetry-api>=1.20.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: coverage>=7.0; extra == 'dev'
22
+ Requires-Dist: opentelemetry-sdk>=1.20.0; extra == 'dev'
23
+ Requires-Dist: opentelemetry-test-utils>=0.40b0; extra == 'dev'
24
+ Requires-Dist: pyright>=1.1; extra == 'dev'
25
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.1; extra == 'dev'
28
+ Provides-Extra: sdk
29
+ Requires-Dist: opentelemetry-sdk>=1.20.0; extra == 'sdk'
30
+ Provides-Extra: test
31
+ Requires-Dist: coverage>=7.0; extra == 'test'
32
+ Requires-Dist: opentelemetry-sdk>=1.20.0; extra == 'test'
33
+ Requires-Dist: opentelemetry-test-utils>=0.40b0; extra == 'test'
34
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
35
+ Requires-Dist: pytest>=7.0; extra == 'test'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # aiotrace – Async context propagation for OpenTelemetry
39
+
40
+ [![PyPI](https://img.shields.io/pypi/v/aiotrace)](https://pypi.org/project/aiotrace/)
41
+ [![Python Versions](https://img.shields.io/pypi/pyversions/aiotrace)](https://pypi.org/project/aiotrace/)
42
+ [![License](https://img.shields.io/pypi/l/aiotrace)](https://github.com/Kubenew/aiotrace/blob/main/LICENSE)
43
+ [![GitHub](https://img.shields.io/github/stars/Kubenew/aiotrace?style=social)](https://github.com/Kubenew/aiotrace)
44
+ [![Tests](https://img.shields.io/github/actions/workflow/status/Kubenew/aiotrace/ci.yml?label=tests)](https://github.com/Kubenew/aiotrace/actions)
45
+
46
+ Fixes OpenTelemetry context propagation across asyncio boundaries:
47
+ `create_task`, `Queue`, `Lock`, `Event`, `Semaphore`, `TaskGroup`, and
48
+ `run_in_executor` (thread pool).
49
+
50
+ ## Problem
51
+
52
+ OpenTelemetry stores the current span in a `contextvars.ContextVar`.
53
+ When asyncio tasks are created or resumed, Python copies/shallow-copies
54
+ these contextvars. On Python < 3.9.17 / 3.10.7 / 3.11.1, `Task.__step`
55
+ does **not** restore the task's own context, causing:
56
+
57
+ * Spans created in child tasks appearing under the wrong parent
58
+ * Context leaking between producer/consumer queues
59
+ * Lost context when using `run_in_executor` (threads)
60
+
61
+ ## Quick Start
62
+
63
+ ```python
64
+ import asyncio
65
+ from opentelemetry import trace
66
+ from aiotrace import install
67
+
68
+ install()
69
+
70
+ tracer = trace.get_tracer(__name__)
71
+
72
+ async def child():
73
+ with tracer.start_as_current_span("child"):
74
+ pass
75
+
76
+ async def main():
77
+ with tracer.start_as_current_span("parent"):
78
+ await asyncio.create_task(child())
79
+
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ ## Manual Usage (no monkey-patching)
84
+
85
+ ```python
86
+ from aiotrace import create_task_with_context, PropagatingQueue
87
+
88
+ # Use explicit wrapper instead of monkey-patch
89
+ task = create_task_with_context(some_coro())
90
+
91
+ # Explicit propagating queue
92
+ queue = PropagatingQueue()
93
+ await queue.put(item)
94
+ item = await queue.get()
95
+ ```
96
+
97
+ ## Installation
98
+
99
+ ```bash
100
+ pip install aiotrace
101
+ ```
102
+
103
+ ## What Gets Patched
104
+
105
+ | Primitive | Replacement | Description |
106
+ |-----------|-------------|-------------|
107
+ | `asyncio.create_task` | Wrapped | Captures OTEL context at task creation, restores inside child |
108
+ | `asyncio.Queue` | `PropagatingQueue` | Re-attaches context after `get()`/`put()` resume |
109
+ | `asyncio.Lock` | `PropagatingLock` | Re-attaches context after `acquire()` |
110
+ | `asyncio.Event` | `PropagatingEvent` | Re-attaches context after `wait()` |
111
+ | `asyncio.Semaphore` | `PropagatingSemaphore` | Re-attaches context after `acquire()` |
112
+ | `asyncio.Condition` | `PropagatingCondition` | Re-attaches context after `wait()` |
113
+ | `asyncio.TaskGroup` (3.11+) | `PropagatingTaskGroup` | Captures context at `create_task()` |
114
+
115
+ ## API
116
+
117
+ ### `install(patch_queue=True, patch_locks=True)`
118
+
119
+ Monkey-patches asyncio primitives. Safe to call multiple times (idempotent).
120
+
121
+ ### `uninstall()`
122
+
123
+ Restores original asyncio primitives.
124
+
125
+ ### `PropagatingQueue(maxsize=0)`
126
+
127
+ Drop-in replacement for `asyncio.Queue` with context propagation.
128
+
129
+ ### `PropagatingLock`, `PropagatingEvent`, `PropagatingSemaphore`, `PropagatingCondition`
130
+
131
+ Drop-in replacements for synchronization primitives.
132
+
133
+ ### `create_task_with_context(coro, *, name=None, otel_ctx=None)`
134
+
135
+ Create a task with explicit OTEL context propagation.
136
+
137
+ ### `run_in_executor_with_context(executor, func, *args)`
138
+
139
+ Schedule a function in a thread pool with OTEL context.
140
+
141
+ ## Requirements
142
+
143
+ * Python 3.8–3.12
144
+ * `opentelemetry-api >= 1.20.0`
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,111 @@
1
+ # aiotrace – Async context propagation for OpenTelemetry
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/aiotrace)](https://pypi.org/project/aiotrace/)
4
+ [![Python Versions](https://img.shields.io/pypi/pyversions/aiotrace)](https://pypi.org/project/aiotrace/)
5
+ [![License](https://img.shields.io/pypi/l/aiotrace)](https://github.com/Kubenew/aiotrace/blob/main/LICENSE)
6
+ [![GitHub](https://img.shields.io/github/stars/Kubenew/aiotrace?style=social)](https://github.com/Kubenew/aiotrace)
7
+ [![Tests](https://img.shields.io/github/actions/workflow/status/Kubenew/aiotrace/ci.yml?label=tests)](https://github.com/Kubenew/aiotrace/actions)
8
+
9
+ Fixes OpenTelemetry context propagation across asyncio boundaries:
10
+ `create_task`, `Queue`, `Lock`, `Event`, `Semaphore`, `TaskGroup`, and
11
+ `run_in_executor` (thread pool).
12
+
13
+ ## Problem
14
+
15
+ OpenTelemetry stores the current span in a `contextvars.ContextVar`.
16
+ When asyncio tasks are created or resumed, Python copies/shallow-copies
17
+ these contextvars. On Python < 3.9.17 / 3.10.7 / 3.11.1, `Task.__step`
18
+ does **not** restore the task's own context, causing:
19
+
20
+ * Spans created in child tasks appearing under the wrong parent
21
+ * Context leaking between producer/consumer queues
22
+ * Lost context when using `run_in_executor` (threads)
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ import asyncio
28
+ from opentelemetry import trace
29
+ from aiotrace import install
30
+
31
+ install()
32
+
33
+ tracer = trace.get_tracer(__name__)
34
+
35
+ async def child():
36
+ with tracer.start_as_current_span("child"):
37
+ pass
38
+
39
+ async def main():
40
+ with tracer.start_as_current_span("parent"):
41
+ await asyncio.create_task(child())
42
+
43
+ asyncio.run(main())
44
+ ```
45
+
46
+ ## Manual Usage (no monkey-patching)
47
+
48
+ ```python
49
+ from aiotrace import create_task_with_context, PropagatingQueue
50
+
51
+ # Use explicit wrapper instead of monkey-patch
52
+ task = create_task_with_context(some_coro())
53
+
54
+ # Explicit propagating queue
55
+ queue = PropagatingQueue()
56
+ await queue.put(item)
57
+ item = await queue.get()
58
+ ```
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install aiotrace
64
+ ```
65
+
66
+ ## What Gets Patched
67
+
68
+ | Primitive | Replacement | Description |
69
+ |-----------|-------------|-------------|
70
+ | `asyncio.create_task` | Wrapped | Captures OTEL context at task creation, restores inside child |
71
+ | `asyncio.Queue` | `PropagatingQueue` | Re-attaches context after `get()`/`put()` resume |
72
+ | `asyncio.Lock` | `PropagatingLock` | Re-attaches context after `acquire()` |
73
+ | `asyncio.Event` | `PropagatingEvent` | Re-attaches context after `wait()` |
74
+ | `asyncio.Semaphore` | `PropagatingSemaphore` | Re-attaches context after `acquire()` |
75
+ | `asyncio.Condition` | `PropagatingCondition` | Re-attaches context after `wait()` |
76
+ | `asyncio.TaskGroup` (3.11+) | `PropagatingTaskGroup` | Captures context at `create_task()` |
77
+
78
+ ## API
79
+
80
+ ### `install(patch_queue=True, patch_locks=True)`
81
+
82
+ Monkey-patches asyncio primitives. Safe to call multiple times (idempotent).
83
+
84
+ ### `uninstall()`
85
+
86
+ Restores original asyncio primitives.
87
+
88
+ ### `PropagatingQueue(maxsize=0)`
89
+
90
+ Drop-in replacement for `asyncio.Queue` with context propagation.
91
+
92
+ ### `PropagatingLock`, `PropagatingEvent`, `PropagatingSemaphore`, `PropagatingCondition`
93
+
94
+ Drop-in replacements for synchronization primitives.
95
+
96
+ ### `create_task_with_context(coro, *, name=None, otel_ctx=None)`
97
+
98
+ Create a task with explicit OTEL context propagation.
99
+
100
+ ### `run_in_executor_with_context(executor, func, *args)`
101
+
102
+ Schedule a function in a thread pool with OTEL context.
103
+
104
+ ## Requirements
105
+
106
+ * Python 3.8–3.12
107
+ * `opentelemetry-api >= 1.20.0`
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aiotrace"
7
+ version = "0.1.0"
8
+ description = "OpenTelemetry-native async context propagation for asyncio"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "aiotrace contributors" },
13
+ ]
14
+ keywords = ["opentelemetry", "asyncio", "contextvars", "tracing", "observability"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3.8",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: System :: Monitoring",
26
+ ]
27
+ dependencies = [
28
+ "opentelemetry-api>=1.20.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ sdk = ["opentelemetry-sdk>=1.20.0"]
33
+ test = [
34
+ "pytest>=7.0",
35
+ "pytest-asyncio>=0.21",
36
+ "opentelemetry-sdk>=1.20.0",
37
+ "opentelemetry-test-utils>=0.40b0",
38
+ "coverage>=7.0",
39
+ ]
40
+ dev = ["aiotrace[test]", "ruff>=0.1", "pyright>=1.1"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ target-version = "py38"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "W"]
48
+
49
+ [tool.pytest.ini_options]
50
+ asyncio_mode = "auto"
51
+ testpaths = ["tests"]
52
+ filterwarnings = ["ignore::DeprecationWarning"]
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/aiotrace"]
56
+
57
+ [tool.pyright]
58
+ strict = true
59
+ include = ["src/aiotrace"]
@@ -0,0 +1,19 @@
1
+ """aiotrace — OpenTelemetry-native async context propagation for asyncio."""
2
+
3
+ from aiotrace._install import install, is_installed, uninstall
4
+ from aiotrace._lock import PropagatingLock
5
+ from aiotrace._queue import PropagatingQueue
6
+ from aiotrace._task import create_task_with_context, unwrap_create_task, wrap_create_task
7
+
8
+ __all__ = [
9
+ "install",
10
+ "uninstall",
11
+ "is_installed",
12
+ "wrap_create_task",
13
+ "unwrap_create_task",
14
+ "create_task_with_context",
15
+ "PropagatingQueue",
16
+ "PropagatingLock",
17
+ ]
18
+
19
+ __version__ = "0.1.0"
@@ -0,0 +1,64 @@
1
+ """Executor (thread-pool) context propagation for OpenTelemetry.
2
+
3
+ ``run_in_executor`` runs the callable in a thread-pool thread, which
4
+ does **not** have access to the asyncio task's ``contextvars``.
5
+ OTEL context must be explicitly copied to the worker thread.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from typing import Any, Callable, Optional, TypeVar
12
+
13
+ from opentelemetry import context as otel_context
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ def run_in_executor_with_context(
19
+ executor: Optional[Any],
20
+ func: Callable[..., T],
21
+ *args: Any,
22
+ ) -> asyncio.Future[T]:
23
+ """Schedule ``func(*args, **kwargs)`` in an executor, preserving OTEL context.
24
+
25
+ Usage::
26
+
27
+ result = await run_in_executor_with_context(None, blocking_io, path)
28
+ """
29
+ ctx = otel_context.get_current()
30
+
31
+ def wrapper() -> T:
32
+ token = otel_context.attach(ctx)
33
+ try:
34
+ return func(*args)
35
+ finally:
36
+ otel_context.detach(token)
37
+
38
+ loop = asyncio.get_running_loop()
39
+ return loop.run_in_executor(executor, wrapper)
40
+
41
+
42
+ def wrap_run_in_executor(loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
43
+ """Monkey-patch ``loop.run_in_executor`` to preserve OTEL context."""
44
+ if loop is None:
45
+ loop = asyncio.get_running_loop()
46
+ original = loop.run_in_executor
47
+
48
+ def _run_in_executor_with_context(
49
+ executor: Optional[Any],
50
+ func: Callable[..., T],
51
+ *args: Any,
52
+ ) -> asyncio.Future[T]:
53
+ ctx = otel_context.get_current()
54
+
55
+ def wrapper() -> T:
56
+ token = otel_context.attach(ctx)
57
+ try:
58
+ return func(*args)
59
+ finally:
60
+ otel_context.detach(token)
61
+
62
+ return original(executor, wrapper)
63
+
64
+ loop.run_in_executor = _run_in_executor_with_context
@@ -0,0 +1,61 @@
1
+ """Central ``install()`` / ``uninstall()`` entry point.
2
+
3
+ Calling ``install()`` monkey-patches ``asyncio.create_task``,
4
+ ``asyncio.Queue``, ``asyncio.Lock``, etc., with context-propagating
5
+ wrappers. Use ``uninstall()`` to restore the originals.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from aiotrace._lock import patch_locks
11
+ from aiotrace._queue import patch_queue
12
+ from aiotrace._task import unwrap_create_task, wrap_create_task
13
+ from aiotrace._taskgroup import patch_taskgroup
14
+
15
+ _installed = False
16
+
17
+
18
+ def install(*, queue: bool = True, locks: bool = True) -> None:
19
+ """Monkey-patch asyncio primitives to propagate OTEL context.
20
+
21
+ Patches applied:
22
+
23
+ * ``asyncio.create_task`` — wraps child coroutines to restore
24
+ the parent's OTEL context at creation time.
25
+ * ``asyncio.Queue`` → :class:`aiotrace.PropagatingQueue` (opt-in).
26
+ * ``asyncio.{Lock,Event,Semaphore,Condition}`` → propagating
27
+ versions (opt-in).
28
+ * ``asyncio.TaskGroup`` → :class:`aiotrace.PropagatingTaskGroup`
29
+ (Python 3.11+).
30
+
31
+ Args:
32
+ queue: Whether to replace ``asyncio.Queue``.
33
+ locks: Whether to replace lock primitives.
34
+ """
35
+ global _installed
36
+ if _installed:
37
+ return
38
+
39
+ wrap_create_task()
40
+
41
+ if queue:
42
+ patch_queue()
43
+
44
+ if locks:
45
+ patch_locks()
46
+
47
+ patch_taskgroup()
48
+
49
+ _installed = True
50
+
51
+
52
+ def uninstall() -> None:
53
+ """Restore all original asyncio primitives."""
54
+ global _installed
55
+ unwrap_create_task()
56
+ _installed = False
57
+
58
+
59
+ def is_installed() -> bool:
60
+ """Return ``True`` if ``install()`` has been called."""
61
+ return _installed
@@ -0,0 +1,60 @@
1
+ """Lock/Event/Semaphore context propagation for OpenTelemetry.
2
+
3
+ Same problem as Queue: when a coroutine is resumed after acquiring
4
+ a lock (or waiting on an event/semaphore), it may wake in the wrong
5
+ context on older Python versions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from opentelemetry import context as otel_context
13
+
14
+
15
+ class PropagatingLock(asyncio.Lock):
16
+ """An ``asyncio.Lock`` that preserves OTEL context across ``acquire``."""
17
+
18
+ async def acquire(self) -> bool:
19
+ ctx = otel_context.get_current()
20
+ result = await super().acquire()
21
+ otel_context.attach(ctx)
22
+ return result
23
+
24
+
25
+ class PropagatingEvent(asyncio.Event):
26
+ """An ``asyncio.Event`` that preserves OTEL context across ``wait``."""
27
+
28
+ async def wait(self) -> bool:
29
+ ctx = otel_context.get_current()
30
+ result = await super().wait()
31
+ otel_context.attach(ctx)
32
+ return result
33
+
34
+
35
+ class PropagatingSemaphore(asyncio.Semaphore):
36
+ """An ``asyncio.Semaphore`` that preserves OTEL context across ``acquire``."""
37
+
38
+ async def acquire(self) -> bool:
39
+ ctx = otel_context.get_current()
40
+ result = await super().acquire()
41
+ otel_context.attach(ctx)
42
+ return result
43
+
44
+
45
+ class PropagatingCondition(asyncio.Condition):
46
+ """An ``asyncio.Condition`` that preserves OTEL context across ``wait``."""
47
+
48
+ async def wait(self) -> bool:
49
+ ctx = otel_context.get_current()
50
+ result = await super().wait()
51
+ otel_context.attach(ctx)
52
+ return result
53
+
54
+
55
+ def patch_locks() -> None:
56
+ """Replace stdlib synchronization primitives with propagating versions."""
57
+ asyncio.Lock = PropagatingLock # type: ignore[misc]
58
+ asyncio.Event = PropagatingEvent # type: ignore[misc]
59
+ asyncio.Semaphore = PropagatingSemaphore # type: ignore[misc]
60
+ asyncio.Condition = PropagatingCondition # type: ignore[misc]
@@ -0,0 +1,41 @@
1
+ """Queue context propagation for OpenTelemetry.
2
+
3
+ On Python < 3.9.17 / 3.10.7 / 3.11.1, ``Task.__step`` does not restore
4
+ the task's own context, so ``await queue.get()`` can resume the consumer
5
+ in the **producer's** context. ``PropagatingQueue`` re-attaches the
6
+ consumer's OTEL context after every resume, fixing span nesting.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any
13
+
14
+ from opentelemetry import context as otel_context
15
+
16
+
17
+ class PropagatingQueue(asyncio.Queue):
18
+ """An ``asyncio.Queue`` that preserves OTEL context across ``get``/``put``.
19
+
20
+ Usage is identical to ``asyncio.Queue``::
21
+
22
+ queue = PropagatingQueue(maxsize=10)
23
+ await queue.put(item)
24
+ item = await queue.get()
25
+ """
26
+
27
+ async def get(self) -> Any:
28
+ ctx = otel_context.get_current()
29
+ item = await super().get()
30
+ otel_context.attach(ctx)
31
+ return item
32
+
33
+ async def put(self, item: Any) -> None:
34
+ ctx = otel_context.get_current()
35
+ await super().put(item)
36
+ otel_context.attach(ctx)
37
+
38
+
39
+ def patch_queue() -> None:
40
+ """Replace ``asyncio.Queue`` with ``PropagatingQueue`` globally."""
41
+ asyncio.Queue = PropagatingQueue # type: ignore[misc]
@@ -0,0 +1,122 @@
1
+ """Task context propagation for OpenTelemetry.
2
+
3
+ Captures the OTEL context at task-creation time and restores it
4
+ inside the child task so that spans created in the child correctly
5
+ nest under the parent span.
6
+ """
7
+
8
+ import asyncio
9
+ from functools import wraps
10
+ from typing import Any, Callable, Coroutine, Optional, TypeVar
11
+
12
+ from opentelemetry import context as otel_context
13
+
14
+ T = TypeVar("T")
15
+
16
+ _original_create_task: Optional[Callable[..., asyncio.Task]] = None
17
+
18
+
19
+ def create_task_with_context(
20
+ coro: Coroutine[Any, Any, T],
21
+ *,
22
+ name: Optional[str] = None,
23
+ otel_ctx: Optional[otel_context.Context] = None,
24
+ ) -> asyncio.Task[T]:
25
+ """Create an asyncio Task that preserves the current OTEL context.
26
+
27
+ Unlike ``asyncio.create_task``, which relies on ``contextvars.copy_context()``
28
+ at ``Task.__init__`` time, this wrapper explicitly captures the **OTEL**
29
+ context and restores it inside the coroutine wrapper. This guarantees
30
+ that the child task sees the same OTEL context even if the parent's
31
+ context changes between creation and first ``await``.
32
+
33
+ Args:
34
+ coro: The coroutine to run in the new task.
35
+ name: Optional task name (passed through to ``asyncio.create_task``).
36
+ otel_ctx: An explicit OTEL context to use. Defaults to
37
+ ``otel_context.get_current()`` at call time.
38
+
39
+ Returns:
40
+ A new ``asyncio.Task`` instance.
41
+ """
42
+ ctx = otel_ctx if otel_ctx is not None else otel_context.get_current()
43
+
44
+ async def _wrapper() -> T:
45
+ token = otel_context.attach(ctx)
46
+ try:
47
+ return await coro
48
+ finally:
49
+ otel_context.detach(token)
50
+
51
+ return asyncio.create_task(_wrapper(), name=name)
52
+
53
+
54
+ def wrap_create_task() -> None:
55
+ """Replace ``asyncio.create_task`` with a context-propagating version.
56
+
57
+ This is a lighter alternative to ``install()`` – it only patches
58
+ the task-creation boundary, not queues or locks.
59
+ """
60
+ global _original_create_task
61
+ if _original_create_task is not None:
62
+ return # already wrapped
63
+
64
+ _original_create_task = asyncio.create_task
65
+
66
+ @wraps(_original_create_task)
67
+ def _create_task_with_context(
68
+ coro: Coroutine[Any, Any, T],
69
+ *,
70
+ name: Optional[str] = None,
71
+ context: Optional[otel_context.Context] = None,
72
+ ) -> asyncio.Task[T]:
73
+ ctx = context or otel_context.get_current()
74
+
75
+ async def _wrapper() -> T:
76
+ token = otel_context.attach(ctx)
77
+ try:
78
+ return await coro
79
+ finally:
80
+ otel_context.detach(token)
81
+
82
+ return _original_create_task(_wrapper(), name=name)
83
+
84
+ asyncio.create_task = _create_task_with_context
85
+
86
+
87
+ def unwrap_create_task() -> None:
88
+ """Restore the original ``asyncio.create_task``."""
89
+ global _original_create_task
90
+ if _original_create_task is not None:
91
+ asyncio.create_task = _original_create_task
92
+ _original_create_task = None
93
+
94
+
95
+ def _patch_loop_create_task(loop: asyncio.AbstractEventLoop) -> None:
96
+ """Patch ``loop.create_task`` for the given event loop.
97
+
98
+ Some libraries call ``loop.create_task`` directly instead of
99
+ ``asyncio.create_task``. This function patches the loop-level
100
+ method for completeness.
101
+ """
102
+ original = loop.create_task
103
+
104
+ @wraps(original)
105
+ def _loop_create_task(
106
+ coro: Coroutine[Any, Any, T],
107
+ *,
108
+ name: Optional[str] = None,
109
+ context: Optional[otel_context.Context] = None,
110
+ ) -> asyncio.Task[T]:
111
+ ctx = context or otel_context.get_current()
112
+
113
+ async def _wrapper() -> T:
114
+ token = otel_context.attach(ctx)
115
+ try:
116
+ return await coro
117
+ finally:
118
+ otel_context.detach(token)
119
+
120
+ return original(_wrapper(), name=name)
121
+
122
+ loop.create_task = _loop_create_task
@@ -0,0 +1,63 @@
1
+ """TaskGroup context propagation for OpenTelemetry.
2
+
3
+ :func:`asyncio.TaskGroup` (Python 3.11+) creates child tasks via
4
+ ``loop.create_task``. If the loop-level method has not been patched,
5
+ the child tasks may inherit a stale context.
6
+
7
+ This module provides ``PropagatingTaskGroup`` which explicitly captures
8
+ the OTEL context at ``create_task`` time and wraps the child coroutine.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import sys
15
+ from typing import Any, Coroutine, Optional, TypeVar
16
+
17
+ from opentelemetry import context as otel_context
18
+
19
+ T = TypeVar("T")
20
+
21
+ if sys.version_info >= (3, 11):
22
+
23
+ class PropagatingTaskGroup(asyncio.TaskGroup):
24
+ """An :class:`asyncio.TaskGroup` that preserves OTEL context.
25
+
26
+ Usage::
27
+
28
+ async with PropagatingTaskGroup() as tg:
29
+ tg.create_task(some_coro())
30
+ """
31
+
32
+ def create_task(
33
+ self,
34
+ coro: Coroutine[Any, Any, T],
35
+ *,
36
+ name: Optional[str] = None,
37
+ context: Optional[otel_context.Context] = None,
38
+ ) -> asyncio.Task[T]:
39
+ ctx = context or otel_context.get_current()
40
+
41
+ async def _wrapper() -> T:
42
+ token = otel_context.attach(ctx)
43
+ try:
44
+ return await coro
45
+ finally:
46
+ otel_context.detach(token)
47
+
48
+ return super().create_task(_wrapper(), name=name)
49
+
50
+ def patch_taskgroup() -> None:
51
+ """Replace ``asyncio.TaskGroup`` with ``PropagatingTaskGroup``."""
52
+ asyncio.TaskGroup = PropagatingTaskGroup # type: ignore[misc]
53
+
54
+ else:
55
+
56
+ class PropagatingTaskGroup: # type: ignore[no-redef]
57
+ """Placeholder — ``asyncio.TaskGroup`` requires Python 3.11+."""
58
+
59
+ def __init__(self, *args: Any, **kwargs: Any):
60
+ raise RuntimeError("TaskGroup requires Python 3.11+")
61
+
62
+ def patch_taskgroup() -> None:
63
+ """No-op on Python < 3.11."""
File without changes
@@ -0,0 +1,48 @@
1
+ """pytest fixtures for aiotrace tests.
2
+
3
+ WARNING: ``set_tracer_provider`` uses an internal ``Once`` guard — calling
4
+ ``get_tracer_provider()`` during import (e.g. via module-level code in any
5
+ test file) consumes it, making all subsequent ``set_tracer_provider()``
6
+ calls no-ops. We work around this by directly assigning
7
+ ``trace._TRACER_PROVIDER`` instead.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+ from opentelemetry import trace
14
+ from opentelemetry.sdk.trace import TracerProvider
15
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor, Span, SpanExporter
16
+
17
+
18
+ class CapturingExporter(SpanExporter):
19
+ """Collects exported spans in memory for test assertions."""
20
+
21
+ def __init__(self):
22
+ self.spans: list[Span] = []
23
+
24
+ def export(self, spans: list[Span]) -> None:
25
+ self.spans.extend(spans)
26
+
27
+ def shutdown(self) -> None:
28
+ self.spans.clear()
29
+
30
+
31
+ @pytest.fixture
32
+ def exporter() -> CapturingExporter:
33
+ return CapturingExporter()
34
+
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def setup_otel(exporter: CapturingExporter):
38
+ provider = TracerProvider()
39
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
40
+ # Direct assignment to bypass OTEL's Once guard
41
+ trace._TRACER_PROVIDER = provider
42
+ yield
43
+ trace._TRACER_PROVIDER = None
44
+
45
+
46
+ @pytest.fixture
47
+ def tracer():
48
+ return trace.get_tracer("aiotrace.test")
@@ -0,0 +1,41 @@
1
+ """Tests for the install/uninstall mechanism."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import pytest
8
+
9
+ from aiotrace import install, is_installed, uninstall
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ async def test_install_and_uninstall():
14
+ assert not is_installed()
15
+ install()
16
+ assert is_installed()
17
+ uninstall()
18
+ assert not is_installed()
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_install_idempotent():
23
+ install()
24
+ assert is_installed()
25
+ # Second call should be a no-op
26
+ install()
27
+ assert is_installed()
28
+ uninstall()
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_create_task_still_works_after_install():
33
+ install()
34
+ try:
35
+ async def dummy():
36
+ return 42
37
+
38
+ result = await asyncio.create_task(dummy())
39
+ assert result == 42
40
+ finally:
41
+ uninstall()
@@ -0,0 +1,67 @@
1
+ """Tests for queue context propagation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import pytest
8
+
9
+ from aiotrace import PropagatingQueue
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ async def test_queue_producer_consumer_spans(tracer, exporter):
14
+ async def consumer(q: asyncio.Queue):
15
+ item = await q.get()
16
+ with tracer.start_as_current_span("process"):
17
+ pass
18
+ q.task_done()
19
+ return item
20
+
21
+ async def producer(q: asyncio.Queue):
22
+ with tracer.start_as_current_span("produce"):
23
+ await q.put("item")
24
+
25
+ q = PropagatingQueue()
26
+ prod = asyncio.create_task(producer(q))
27
+ cons = asyncio.create_task(consumer(q))
28
+ await asyncio.gather(prod, cons)
29
+ await q.join()
30
+
31
+ names = {s.name for s in exporter.spans}
32
+ assert "produce" in names
33
+ assert "process" in names
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_queue_multiple_consumers(tracer, exporter):
38
+ async def consumer(q: asyncio.Queue, name: str):
39
+ await q.get()
40
+ with tracer.start_as_current_span(f"consume_{name}"):
41
+ pass
42
+ q.task_done()
43
+
44
+ async def producer(q: asyncio.Queue):
45
+ with tracer.start_as_current_span("produce"):
46
+ for i in range(3):
47
+ await q.put(i)
48
+
49
+ q = PropagatingQueue(maxsize=1)
50
+ prod = asyncio.create_task(producer(q))
51
+ cons = [asyncio.create_task(consumer(q, str(i))) for i in range(3)]
52
+ await asyncio.gather(prod, *cons)
53
+ await q.join()
54
+
55
+ assert len(exporter.spans) >= 4
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_queue_standard_behavior(tracer, exporter):
60
+ q = PropagatingQueue()
61
+ await q.put(1)
62
+ await q.put(2)
63
+ result = await q.get()
64
+ assert result == 1
65
+ result = await q.get()
66
+ assert result == 2
67
+ assert q.empty()
@@ -0,0 +1,131 @@
1
+ """Tests for task context propagation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+
8
+ import pytest
9
+
10
+ from aiotrace import unwrap_create_task, wrap_create_task
11
+
12
+
13
+ @pytest.mark.asyncio
14
+ async def test_child_task_inherits_parent_context(tracer, exporter):
15
+ wrap_create_task()
16
+ try:
17
+ async def child():
18
+ with tracer.start_as_current_span("child"):
19
+ pass
20
+
21
+ async def parent():
22
+ with tracer.start_as_current_span("parent"):
23
+ await asyncio.create_task(child())
24
+
25
+ await parent()
26
+ assert len(exporter.spans) == 2
27
+ names = {s.name for s in exporter.spans}
28
+ assert names == {"parent", "child"}
29
+ finally:
30
+ unwrap_create_task()
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_gather_preserves_context(tracer, exporter):
35
+ wrap_create_task()
36
+ try:
37
+ async def child(n):
38
+ with tracer.start_as_current_span(f"child_{n}"):
39
+ pass
40
+
41
+ async def parent():
42
+ with tracer.start_as_current_span("parent"):
43
+ await asyncio.gather(child(1), child(2), child(3))
44
+
45
+ await parent()
46
+ assert len(exporter.spans) == 4
47
+ finally:
48
+ unwrap_create_task()
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_nested_create_task(tracer, exporter):
53
+ wrap_create_task()
54
+ try:
55
+ async def inner():
56
+ with tracer.start_as_current_span("inner"):
57
+ pass
58
+
59
+ async def outer():
60
+ with tracer.start_as_current_span("outer"):
61
+ await asyncio.create_task(inner())
62
+
63
+ await outer()
64
+ assert len(exporter.spans) == 2
65
+ names = {s.name for s in exporter.spans}
66
+ assert names == {"outer", "inner"}
67
+ finally:
68
+ unwrap_create_task()
69
+
70
+
71
+ @pytest.mark.skipif(sys.version_info < (3, 11), reason="TaskGroup requires 3.11+")
72
+ @pytest.mark.asyncio
73
+ async def test_create_task_in_taskgroup(tracer, exporter):
74
+ wrap_create_task()
75
+ try:
76
+ async def child():
77
+ with tracer.start_as_current_span("child"):
78
+ pass
79
+
80
+ async def parent():
81
+ with tracer.start_as_current_span("parent"):
82
+ async with asyncio.TaskGroup() as tg:
83
+ tg.create_task(child())
84
+ tg.create_task(child())
85
+
86
+ await parent()
87
+ assert len(exporter.spans) == 3
88
+ finally:
89
+ unwrap_create_task()
90
+
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_context_maintained_after_task_cancel(tracer, exporter):
94
+ wrap_create_task()
95
+ try:
96
+ async def cancellable():
97
+ try:
98
+ await asyncio.sleep(10)
99
+ except asyncio.CancelledError:
100
+ raise
101
+
102
+ async def parent():
103
+ with tracer.start_as_current_span("parent"):
104
+ task = asyncio.create_task(cancellable())
105
+ await asyncio.sleep(0.01)
106
+ task.cancel()
107
+ try:
108
+ await task
109
+ except asyncio.CancelledError:
110
+ pass
111
+
112
+ await parent()
113
+ assert len(exporter.spans) == 1
114
+ assert exporter.spans[0].name == "parent"
115
+ finally:
116
+ unwrap_create_task()
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_create_task_without_patch(tracer, exporter):
121
+ """Sanity check: standard create_task on modern Python."""
122
+ async def child():
123
+ with tracer.start_as_current_span("child"):
124
+ pass
125
+
126
+ async def parent():
127
+ with tracer.start_as_current_span("parent"):
128
+ await asyncio.create_task(child())
129
+
130
+ await parent()
131
+ assert len(exporter.spans) == 2