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.
- aiotrace-0.1.0/.gitignore +14 -0
- aiotrace-0.1.0/LICENSE +21 -0
- aiotrace-0.1.0/PKG-INFO +148 -0
- aiotrace-0.1.0/README.md +111 -0
- aiotrace-0.1.0/pyproject.toml +59 -0
- aiotrace-0.1.0/src/aiotrace/__init__.py +19 -0
- aiotrace-0.1.0/src/aiotrace/_executor.py +64 -0
- aiotrace-0.1.0/src/aiotrace/_install.py +61 -0
- aiotrace-0.1.0/src/aiotrace/_lock.py +60 -0
- aiotrace-0.1.0/src/aiotrace/_queue.py +41 -0
- aiotrace-0.1.0/src/aiotrace/_task.py +122 -0
- aiotrace-0.1.0/src/aiotrace/_taskgroup.py +63 -0
- aiotrace-0.1.0/tests/__init__.py +0 -0
- aiotrace-0.1.0/tests/conftest.py +48 -0
- aiotrace-0.1.0/tests/test_install.py +41 -0
- aiotrace-0.1.0/tests/test_queue.py +67 -0
- aiotrace-0.1.0/tests/test_task.py +131 -0
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.
|
aiotrace-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/aiotrace/)
|
|
41
|
+
[](https://pypi.org/project/aiotrace/)
|
|
42
|
+
[](https://github.com/Kubenew/aiotrace/blob/main/LICENSE)
|
|
43
|
+
[](https://github.com/Kubenew/aiotrace)
|
|
44
|
+
[](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
|
aiotrace-0.1.0/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# aiotrace – Async context propagation for OpenTelemetry
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/aiotrace/)
|
|
4
|
+
[](https://pypi.org/project/aiotrace/)
|
|
5
|
+
[](https://github.com/Kubenew/aiotrace/blob/main/LICENSE)
|
|
6
|
+
[](https://github.com/Kubenew/aiotrace)
|
|
7
|
+
[](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
|