opentelemetry-instrumentation-taskflow 0.0.1__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,13 @@
1
+ .venv/
2
+ .tox/
3
+ .ruff_cache/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ __pycache__/
8
+ *.py[cod]
9
+ .pytest_cache/
10
+ .idea/
11
+ .claude/
12
+ .codegraph/
13
+ uv.lock
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: opentelemetry-instrumentation-taskflow
3
+ Version: 0.0.1
4
+ Summary: OpenStack taskflow instrumentation for OpenTelemetry
5
+ Author-email: Pham Le Gia Dai <daipham3213@gmail.com>
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Programming Language :: Python
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: opentelemetry-api
18
+ Requires-Dist: opentelemetry-instrumentation
19
+ Requires-Dist: opentelemetry-semantic-conventions
20
+ Requires-Dist: opentelemetry-util-http
21
+ Provides-Extra: instruments
22
+ Requires-Dist: taskflow; extra == 'instruments'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # OpenTelemetry TaskFlow Instrumentation
26
+
27
+ OpenStack TaskFlow instrumentation for OpenTelemetry.
28
+
29
+ It records every `execute`/`revert` call on a `taskflow.task.Task` as a span
30
+ named `taskflow.task.<method>`, annotated with the task's class, method and
31
+ name. Because concrete tasks override `execute`/`revert`, the instrumentor wraps
32
+ `Task.__getattribute__` so those overrides are traced wherever they are defined.
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from opentelemetry.instrumentation.taskflow import TaskflowInstrumentor
38
+
39
+ TaskflowInstrumentor().instrument()
40
+ ```
@@ -0,0 +1,16 @@
1
+ # OpenTelemetry TaskFlow Instrumentation
2
+
3
+ OpenStack TaskFlow instrumentation for OpenTelemetry.
4
+
5
+ It records every `execute`/`revert` call on a `taskflow.task.Task` as a span
6
+ named `taskflow.task.<method>`, annotated with the task's class, method and
7
+ name. Because concrete tasks override `execute`/`revert`, the instrumentor wraps
8
+ `Task.__getattribute__` so those overrides are traced wherever they are defined.
9
+
10
+ ## Usage
11
+
12
+ ```python
13
+ from opentelemetry.instrumentation.taskflow import TaskflowInstrumentor
14
+
15
+ TaskflowInstrumentor().instrument()
16
+ ```
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "opentelemetry-instrumentation-taskflow"
3
+ description = "OpenStack taskflow instrumentation for OpenTelemetry"
4
+ readme = "README.md"
5
+ authors = [
6
+ { name = "Pham Le Gia Dai", email = "daipham3213@gmail.com" }
7
+ ]
8
+ requires-python = ">=3.10"
9
+ dynamic = ["version"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: Apache Software License",
14
+ "Programming Language :: Python",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ ]
22
+ dependencies = [
23
+ "opentelemetry-api",
24
+ "opentelemetry-instrumentation",
25
+ "opentelemetry-semantic-conventions",
26
+ "opentelemetry-util-http",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ instruments = [
31
+ "taskflow"
32
+ ]
33
+
34
+ [project.entry-points.opentelemetry_instrumentor]
35
+ taskflow = "opentelemetry.instrumentation.taskflow:TaskflowInstrumentor"
36
+
37
+ [tool.hatch.version]
38
+ path = "src/opentelemetry/instrumentation/taskflow/version.py"
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = [
42
+ "/src",
43
+ "/tests",
44
+ ]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/opentelemetry"]
48
+
49
+ [build-system]
50
+ requires = ["hatchling"]
51
+ build-backend = "hatchling.build"
@@ -0,0 +1,122 @@
1
+ """OpenTelemetry instrumentation for TaskFlow.
2
+
3
+ `TaskFlow <https://docs.openstack.org/taskflow/>`_ models work as *atoms* — most
4
+ commonly :class:`~taskflow.task.Task` objects that implement an ``execute``
5
+ method (to perform work) and an optional ``revert`` method (to roll it back when
6
+ a flow fails). This instrumentor records every ``execute``/``revert`` call as a
7
+ span named ``taskflow.task.<method>``, annotated with the task's class, method
8
+ and name.
9
+
10
+ Concrete tasks override ``execute``/``revert``, so patching the methods on
11
+ :class:`~taskflow.task.Task` itself would never see those overrides. Instead the
12
+ instrumentor wraps ``Task.__getattribute__`` so that accessing either method on
13
+ *any* Task instance returns a traced wrapper, regardless of where the method is
14
+ defined.
15
+
16
+ Usage::
17
+
18
+ from opentelemetry.instrumentation.taskflow import TaskflowInstrumentor
19
+
20
+ TaskflowInstrumentor().instrument()
21
+ """
22
+
23
+ from functools import wraps
24
+ from typing import Collection
25
+
26
+ from opentelemetry import trace
27
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
28
+ from opentelemetry.instrumentation.taskflow.version import __version__
29
+
30
+ try:
31
+ from taskflow.task import Task
32
+ except ImportError:
33
+ Task = None
34
+
35
+ _instruments = ("taskflow",)
36
+ _TRACED_METHODS = frozenset(("execute", "revert"))
37
+
38
+
39
+ def _task_attributes(task, method):
40
+ """Build the span attributes describing ``task`` and the called ``method``."""
41
+ task_type = type(task)
42
+ attributes = {
43
+ "taskflow.task.class": f"{task_type.__module__}.{task_type.__qualname__}",
44
+ "taskflow.task.method": method,
45
+ }
46
+
47
+ task_name = getattr(task, "name", None)
48
+ if task_name is not None:
49
+ attributes["taskflow.task.name"] = task_name
50
+
51
+ return attributes
52
+
53
+
54
+ def _wrap_task_method(task, method_name, method, tracer):
55
+ """Wrap a bound task method so each call is recorded as a span."""
56
+
57
+ @wraps(method)
58
+ def traced_method(*args, **kwargs):
59
+ with tracer.start_as_current_span(
60
+ f"taskflow.task.{method_name}",
61
+ attributes=_task_attributes(task, method_name),
62
+ record_exception=True,
63
+ set_status_on_exception=True,
64
+ ):
65
+ return method(*args, **kwargs)
66
+
67
+ return traced_method
68
+
69
+
70
+ def _wrap_getattribute(getattribute, tracer):
71
+ """Wrap ``Task.__getattribute__`` to trace traced-method lookups."""
72
+
73
+ def traced_getattribute(self, name):
74
+ attribute = getattribute(self, name)
75
+ if name in _TRACED_METHODS and callable(attribute):
76
+ return _wrap_task_method(self, name, attribute, tracer)
77
+ return attribute
78
+
79
+ return traced_getattribute
80
+
81
+
82
+ class TaskflowInstrumentor(BaseInstrumentor):
83
+ """An instrumentor for TaskFlow task atoms.
84
+
85
+ Args:
86
+ tracer_provider: ``TracerProvider`` used to obtain the tracer. Defaults
87
+ to the global provider.
88
+ """
89
+
90
+ _original_getattribute = None
91
+
92
+ def instrumentation_dependencies(self) -> Collection[str]:
93
+ return _instruments
94
+
95
+ def _instrument(self, **kwargs):
96
+ if Task is None:
97
+ return # taskflow is not available
98
+
99
+ if TaskflowInstrumentor._original_getattribute is not None:
100
+ return # already instrumented
101
+
102
+ tracer = trace.get_tracer(
103
+ __name__,
104
+ __version__,
105
+ tracer_provider=kwargs.get("tracer_provider"),
106
+ )
107
+
108
+ TaskflowInstrumentor._original_getattribute = Task.__getattribute__
109
+ Task.__getattribute__ = _wrap_getattribute(
110
+ TaskflowInstrumentor._original_getattribute,
111
+ tracer,
112
+ )
113
+
114
+ def _uninstrument(self, **kwargs):
115
+ if TaskflowInstrumentor._original_getattribute is None:
116
+ return
117
+
118
+ Task.__getattribute__ = TaskflowInstrumentor._original_getattribute
119
+ TaskflowInstrumentor._original_getattribute = None
120
+
121
+
122
+ __all__ = ["TaskflowInstrumentor", "__version__"]
@@ -0,0 +1,134 @@
1
+ import pytest
2
+ from taskflow import task
3
+
4
+ from opentelemetry.instrumentation.taskflow import TaskflowInstrumentor
5
+ from opentelemetry.sdk.trace import TracerProvider
6
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
7
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
8
+ InMemorySpanExporter,
9
+ )
10
+ from opentelemetry.trace import StatusCode
11
+
12
+
13
+ class DemoTask(task.Task):
14
+ def execute(self, **kwargs):
15
+ return kwargs.get("value", "ok")
16
+
17
+ def revert(self, *args, **kwargs):
18
+ return kwargs.get("result", "reverted")
19
+
20
+
21
+ class FailingTask(task.Task):
22
+ def execute(self, **kwargs):
23
+ raise ValueError(kwargs.get("message", "boom"))
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def uninstrument_taskflow():
28
+ TaskflowInstrumentor().uninstrument()
29
+ yield
30
+ TaskflowInstrumentor().uninstrument()
31
+
32
+
33
+ @pytest.fixture
34
+ def span_exporter():
35
+ return InMemorySpanExporter()
36
+
37
+
38
+ @pytest.fixture
39
+ def instrumentor(span_exporter):
40
+ tracer_provider = TracerProvider()
41
+ tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
42
+
43
+ instrumentor = TaskflowInstrumentor()
44
+ instrumentor.instrument(tracer_provider=tracer_provider)
45
+
46
+ yield instrumentor
47
+
48
+ instrumentor.uninstrument()
49
+
50
+
51
+ def assert_task_span(span, *, method, task_name, task_class="DemoTask"):
52
+ assert span.name == f"taskflow.task.{method}"
53
+ assert span.attributes["taskflow.task.class"].endswith(f".{task_class}")
54
+ assert span.attributes["taskflow.task.method"] == method
55
+ assert span.attributes["taskflow.task.name"] == task_name
56
+
57
+
58
+ def test_instrumentation_dependencies():
59
+ assert TaskflowInstrumentor().instrumentation_dependencies() == (
60
+ "taskflow",
61
+ )
62
+
63
+
64
+ @pytest.mark.parametrize(
65
+ ("method", "kwargs", "expected"),
66
+ (
67
+ ("execute", {"value": "done"}, "done"),
68
+ ("revert", {"result": "rolled-back"}, "rolled-back"),
69
+ ),
70
+ )
71
+ def test_task_lifecycle_methods_create_spans(
72
+ instrumentor,
73
+ span_exporter,
74
+ method,
75
+ kwargs,
76
+ expected,
77
+ ):
78
+ result = getattr(DemoTask(name="demo-task"), method)(**kwargs)
79
+
80
+ spans = span_exporter.get_finished_spans()
81
+ assert result == expected
82
+ assert len(spans) == 1
83
+ assert_task_span(spans[0], method=method, task_name="demo-task")
84
+
85
+
86
+ def test_subclass_overrides_are_wrapped(instrumentor, span_exporter):
87
+ # ``execute`` is defined on the subclass, not on ``Task`` — wrapping
88
+ # ``__getattribute__`` is what lets the instrumentor see it.
89
+ assert DemoTask(name="subclass-task").execute(value="done") == "done"
90
+
91
+ spans = span_exporter.get_finished_spans()
92
+ assert len(spans) == 1
93
+ assert_task_span(spans[0], method="execute", task_name="subclass-task")
94
+
95
+
96
+ def test_instrument_twice_does_not_create_nested_duplicate_spans(
97
+ instrumentor,
98
+ span_exporter,
99
+ ):
100
+ instrumentor.instrument()
101
+
102
+ assert DemoTask(name="demo-task").execute(value="done") == "done"
103
+
104
+ spans = span_exporter.get_finished_spans()
105
+ assert len(spans) == 1
106
+ assert_task_span(spans[0], method="execute", task_name="demo-task")
107
+
108
+
109
+ def test_uninstrument_restores_task_without_creating_spans(
110
+ instrumentor,
111
+ span_exporter,
112
+ ):
113
+ instrumentor.uninstrument()
114
+
115
+ result = DemoTask(name="demo-task").execute(value="done")
116
+
117
+ assert result == "done"
118
+ assert span_exporter.get_finished_spans() == ()
119
+
120
+
121
+ def test_exception_is_recorded_on_span(instrumentor, span_exporter):
122
+ with pytest.raises(ValueError, match="boom"):
123
+ FailingTask(name="failing-task").execute(message="boom")
124
+
125
+ spans = span_exporter.get_finished_spans()
126
+ assert len(spans) == 1
127
+ assert_task_span(
128
+ spans[0],
129
+ method="execute",
130
+ task_name="failing-task",
131
+ task_class="FailingTask",
132
+ )
133
+ assert spans[0].status.status_code == StatusCode.ERROR
134
+ assert any(event.name == "exception" for event in spans[0].events)