respan-instrumentation-pytest 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.
- respan_instrumentation_pytest-0.1.0/PKG-INFO +60 -0
- respan_instrumentation_pytest-0.1.0/README.md +39 -0
- respan_instrumentation_pytest-0.1.0/pyproject.toml +28 -0
- respan_instrumentation_pytest-0.1.0/src/respan_instrumentation_pytest/__init__.py +5 -0
- respan_instrumentation_pytest-0.1.0/src/respan_instrumentation_pytest/_constants.py +18 -0
- respan_instrumentation_pytest-0.1.0/src/respan_instrumentation_pytest/_instrumentation.py +289 -0
- respan_instrumentation_pytest-0.1.0/src/respan_instrumentation_pytest/_runtime.py +185 -0
- respan_instrumentation_pytest-0.1.0/src/respan_instrumentation_pytest/plugin.py +104 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: respan-instrumentation-pytest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Respan tracing plugin for Pytest sessions and tests
|
|
5
|
+
License: Apache 2.0
|
|
6
|
+
Author: Respan
|
|
7
|
+
Author-email: team@respan.ai
|
|
8
|
+
Requires-Python: >=3.11,<3.14
|
|
9
|
+
Classifier: Framework :: Pytest
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: opentelemetry-semantic-conventions-ai (>=0.4.1)
|
|
16
|
+
Requires-Dist: pytest (>=7.4.0,<10.0.0)
|
|
17
|
+
Requires-Dist: respan-sdk (>=2.6.1)
|
|
18
|
+
Requires-Dist: respan-tracing (>=2.17.0,<3.0.0)
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# respan-instrumentation-pytest
|
|
22
|
+
|
|
23
|
+
An installable Pytest plugin that emits one canonical Respan workflow span for each test session and one task span for every test protocol. The test span remains current across setup, synchronous or asynchronous execution, and teardown, so application spans created by a test are nested beneath the correct test.
|
|
24
|
+
|
|
25
|
+
The plugin records pass, skip, xfail, setup failure, assertion failure, teardown failure, phase duration, parametrization, markers, and fixture names. Failures include OpenTelemetry error status plus backend-visible `status_code` and `error.message` fields.
|
|
26
|
+
|
|
27
|
+
## Install and enable
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install respan-ai respan-instrumentation-pytest
|
|
31
|
+
pytest --respan-tracing
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Pytest discovers the package through its `pytest11` entry point. It remains opt-in after installation so a globally installed plugin does not unexpectedly export every test run. Enable it in CI with:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
RESPAN_PYTEST_ENABLED=true pytest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The usual `RESPAN_API_KEY` and optional `RESPAN_BASE_URL` variables configure export. Configuration can also live in `pytest.ini`:
|
|
41
|
+
|
|
42
|
+
```ini
|
|
43
|
+
[pytest]
|
|
44
|
+
respan_tracing = true
|
|
45
|
+
respan_capture_content = true
|
|
46
|
+
respan_workflow_name = checkout_integration_tests
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Equivalent environment variables are `RESPAN_PYTEST_ENABLED`, `RESPAN_PYTEST_CAPTURE_CONTENT`, and `RESPAN_PYTEST_WORKFLOW_NAME`.
|
|
50
|
+
|
|
51
|
+
## Content capture
|
|
52
|
+
|
|
53
|
+
By default, test parameters, fixture names, markers, and failure text are captured. Disable sensitive values with:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pytest --respan-tracing --no-respan-capture-content
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
With capture disabled, node IDs, outcomes, phase durations, and exception types remain visible while parameter values and failure messages are omitted. Fixture return values are never recorded. Each xdist worker creates its own session span with worker metadata. The lifecycle is idempotent.
|
|
60
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# respan-instrumentation-pytest
|
|
2
|
+
|
|
3
|
+
An installable Pytest plugin that emits one canonical Respan workflow span for each test session and one task span for every test protocol. The test span remains current across setup, synchronous or asynchronous execution, and teardown, so application spans created by a test are nested beneath the correct test.
|
|
4
|
+
|
|
5
|
+
The plugin records pass, skip, xfail, setup failure, assertion failure, teardown failure, phase duration, parametrization, markers, and fixture names. Failures include OpenTelemetry error status plus backend-visible `status_code` and `error.message` fields.
|
|
6
|
+
|
|
7
|
+
## Install and enable
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install respan-ai respan-instrumentation-pytest
|
|
11
|
+
pytest --respan-tracing
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Pytest discovers the package through its `pytest11` entry point. It remains opt-in after installation so a globally installed plugin does not unexpectedly export every test run. Enable it in CI with:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
RESPAN_PYTEST_ENABLED=true pytest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The usual `RESPAN_API_KEY` and optional `RESPAN_BASE_URL` variables configure export. Configuration can also live in `pytest.ini`:
|
|
21
|
+
|
|
22
|
+
```ini
|
|
23
|
+
[pytest]
|
|
24
|
+
respan_tracing = true
|
|
25
|
+
respan_capture_content = true
|
|
26
|
+
respan_workflow_name = checkout_integration_tests
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Equivalent environment variables are `RESPAN_PYTEST_ENABLED`, `RESPAN_PYTEST_CAPTURE_CONTENT`, and `RESPAN_PYTEST_WORKFLOW_NAME`.
|
|
30
|
+
|
|
31
|
+
## Content capture
|
|
32
|
+
|
|
33
|
+
By default, test parameters, fixture names, markers, and failure text are captured. Disable sensitive values with:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pytest --respan-tracing --no-respan-capture-content
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
With capture disabled, node IDs, outcomes, phase durations, and exception types remain visible while parameter values and failure messages are omitted. Fixture return values are never recorded. Each xdist worker creates its own session span with worker metadata. The lifecycle is idempotent.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "respan-instrumentation-pytest"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Respan tracing plugin for Pytest sessions and tests"
|
|
5
|
+
authors = ["Respan <team@respan.ai>"]
|
|
6
|
+
license = "Apache 2.0"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
classifiers = ["Framework :: Pytest"]
|
|
9
|
+
packages = [
|
|
10
|
+
{ include = "respan_instrumentation_pytest", from = "./src" },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = ">=3.11,<3.14"
|
|
15
|
+
respan-tracing = "^2.17.0"
|
|
16
|
+
respan-sdk = ">=2.6.1"
|
|
17
|
+
pytest = ">=7.4.0,<10.0.0"
|
|
18
|
+
opentelemetry-semantic-conventions-ai = ">=0.4.1"
|
|
19
|
+
|
|
20
|
+
[tool.poetry.plugins."respan.instrumentations"]
|
|
21
|
+
pytest = "respan_instrumentation_pytest:PytestInstrumentor"
|
|
22
|
+
|
|
23
|
+
[tool.poetry.plugins.pytest11]
|
|
24
|
+
respan = "respan_instrumentation_pytest.plugin"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["poetry-core"]
|
|
28
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pytest instrumentation-local constants."""
|
|
2
|
+
|
|
3
|
+
from respan_sdk.constants.llm_logging import LOG_TYPE_TASK, LOG_TYPE_WORKFLOW
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PYTEST_INSTRUMENTATION_NAME = "pytest"
|
|
7
|
+
PYTEST_RUNTIME_PLUGIN_NAME = "respan-pytest-runtime"
|
|
8
|
+
|
|
9
|
+
ENV_ENABLED = "RESPAN_PYTEST_ENABLED"
|
|
10
|
+
ENV_CAPTURE_CONTENT = "RESPAN_PYTEST_CAPTURE_CONTENT"
|
|
11
|
+
ENV_WORKFLOW_NAME = "RESPAN_PYTEST_WORKFLOW_NAME"
|
|
12
|
+
ENV_EXAMPLE_RUN_ID = "RESPAN_EXAMPLE_RUN_ID"
|
|
13
|
+
|
|
14
|
+
TASK_LOG_TYPE = LOG_TYPE_TASK
|
|
15
|
+
WORKFLOW_LOG_TYPE = LOG_TYPE_WORKFLOW
|
|
16
|
+
MAX_ATTRIBUTE_CHARS = 16_000
|
|
17
|
+
MAX_COLLECTION_ITEMS = 50
|
|
18
|
+
MAX_SERIALIZATION_DEPTH = 8
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Pytest hook implementation that emits canonical Respan spans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from opentelemetry import trace
|
|
16
|
+
from opentelemetry.semconv_ai import SpanAttributes
|
|
17
|
+
from opentelemetry.trace import SpanKind
|
|
18
|
+
from respan_instrumentation_pytest._constants import (
|
|
19
|
+
ENV_EXAMPLE_RUN_ID,
|
|
20
|
+
MAX_ATTRIBUTE_CHARS,
|
|
21
|
+
MAX_COLLECTION_ITEMS,
|
|
22
|
+
MAX_SERIALIZATION_DEPTH,
|
|
23
|
+
PYTEST_INSTRUMENTATION_NAME,
|
|
24
|
+
WORKFLOW_LOG_TYPE,
|
|
25
|
+
)
|
|
26
|
+
from respan_sdk.constants.span_attributes import (
|
|
27
|
+
RESPAN_LOG_TYPE,
|
|
28
|
+
RESPAN_METADATA,
|
|
29
|
+
RESPAN_SPAN_CUSTOM_ID,
|
|
30
|
+
RESPAN_TRACE_GROUP_ID,
|
|
31
|
+
)
|
|
32
|
+
from respan_tracing import RespanTelemetry
|
|
33
|
+
from respan_tracing.core.tracer import RespanTracer
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _to_jsonable(value: Any, *, depth: int = 0) -> Any:
|
|
39
|
+
if depth > MAX_SERIALIZATION_DEPTH:
|
|
40
|
+
return repr(value)
|
|
41
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
42
|
+
return value
|
|
43
|
+
if isinstance(value, bytes):
|
|
44
|
+
return {"type": "bytes", "length": len(value)}
|
|
45
|
+
if isinstance(value, Path):
|
|
46
|
+
return str(value)
|
|
47
|
+
if isinstance(value, Mapping):
|
|
48
|
+
items = list(value.items())
|
|
49
|
+
payload = {
|
|
50
|
+
str(key): _to_jsonable(item, depth=depth + 1)
|
|
51
|
+
for key, item in items[:MAX_COLLECTION_ITEMS]
|
|
52
|
+
}
|
|
53
|
+
if len(items) > MAX_COLLECTION_ITEMS:
|
|
54
|
+
payload["_respan_truncated_items"] = len(items) - MAX_COLLECTION_ITEMS
|
|
55
|
+
return payload
|
|
56
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
57
|
+
items = list(value)
|
|
58
|
+
payload = [
|
|
59
|
+
_to_jsonable(item, depth=depth + 1) for item in items[:MAX_COLLECTION_ITEMS]
|
|
60
|
+
]
|
|
61
|
+
if len(items) > MAX_COLLECTION_ITEMS:
|
|
62
|
+
payload.append(
|
|
63
|
+
{"_respan_truncated_items": len(items) - MAX_COLLECTION_ITEMS}
|
|
64
|
+
)
|
|
65
|
+
return payload
|
|
66
|
+
return repr(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _json_dumps(value: Any, *, max_chars: int = MAX_ATTRIBUTE_CHARS) -> str:
|
|
70
|
+
serialized = json.dumps(
|
|
71
|
+
_to_jsonable(value),
|
|
72
|
+
default=str,
|
|
73
|
+
ensure_ascii=False,
|
|
74
|
+
sort_keys=True,
|
|
75
|
+
)
|
|
76
|
+
if len(serialized) <= max_chars:
|
|
77
|
+
return serialized
|
|
78
|
+
return json.dumps(
|
|
79
|
+
{
|
|
80
|
+
"truncated": True,
|
|
81
|
+
"original_characters": len(serialized),
|
|
82
|
+
"preview": serialized[:max_chars],
|
|
83
|
+
},
|
|
84
|
+
ensure_ascii=False,
|
|
85
|
+
sort_keys=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _safe_name(value: str) -> str:
|
|
90
|
+
return "_".join(value.strip().split())[:160] or "pytest_session"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class _TestState:
|
|
95
|
+
span: Any
|
|
96
|
+
context_manager: Any
|
|
97
|
+
reports: dict[str, Any] = field(default_factory=dict)
|
|
98
|
+
durations: dict[str, float] = field(default_factory=dict)
|
|
99
|
+
error: BaseException | None = None
|
|
100
|
+
error_when: str | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PytestInstrumentor:
|
|
104
|
+
"""Actual Pytest plugin and Respan instrumentation lifecycle entrypoint."""
|
|
105
|
+
|
|
106
|
+
name = PYTEST_INSTRUMENTATION_NAME
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
capture_content: bool = True,
|
|
112
|
+
workflow_name: str | None = None,
|
|
113
|
+
tracer: Any = None,
|
|
114
|
+
max_attribute_chars: int = MAX_ATTRIBUTE_CHARS,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._capture_content = capture_content
|
|
117
|
+
self._workflow_name = workflow_name
|
|
118
|
+
self._provided_tracer = tracer
|
|
119
|
+
self._tracer: Any = None
|
|
120
|
+
self._telemetry: RespanTelemetry | None = None
|
|
121
|
+
self._max_attribute_chars = max(512, int(max_attribute_chars))
|
|
122
|
+
self._is_instrumented = False
|
|
123
|
+
self._session_span: Any = None
|
|
124
|
+
self._session_context_manager: Any = None
|
|
125
|
+
self._session: Any = None
|
|
126
|
+
self._test_states: dict[str, _TestState] = {}
|
|
127
|
+
self._outcome_counts: Counter[str] = Counter()
|
|
128
|
+
self._run_id = os.getenv(ENV_EXAMPLE_RUN_ID) or uuid4().hex[:12]
|
|
129
|
+
|
|
130
|
+
def _resolve_tracer(self) -> Any:
|
|
131
|
+
if self._provided_tracer is not None:
|
|
132
|
+
return self._provided_tracer
|
|
133
|
+
if getattr(RespanTracer, "_instance", None) is None:
|
|
134
|
+
self._telemetry = RespanTelemetry(
|
|
135
|
+
app_name=self._workflow_name or "pytest",
|
|
136
|
+
api_key=os.getenv("RESPAN_API_KEY"),
|
|
137
|
+
base_url=os.getenv("RESPAN_BASE_URL"),
|
|
138
|
+
is_auto_instrument=False,
|
|
139
|
+
is_batching_enabled=False,
|
|
140
|
+
)
|
|
141
|
+
return trace.get_tracer(__name__)
|
|
142
|
+
|
|
143
|
+
def activate(self) -> None:
|
|
144
|
+
"""Prepare tracing. Pytest hook registration is handled by `plugin.py`."""
|
|
145
|
+
if self._is_instrumented:
|
|
146
|
+
return
|
|
147
|
+
self._tracer = self._resolve_tracer()
|
|
148
|
+
self._is_instrumented = True
|
|
149
|
+
logger.info("Pytest instrumentation activated")
|
|
150
|
+
|
|
151
|
+
def deactivate(self) -> None:
|
|
152
|
+
"""Close any live spans and flush telemetry without altering test results."""
|
|
153
|
+
for nodeid in tuple(self._test_states):
|
|
154
|
+
state = self._test_states.pop(nodeid)
|
|
155
|
+
try:
|
|
156
|
+
self._finish_test_span(nodeid, state)
|
|
157
|
+
finally:
|
|
158
|
+
state.context_manager.__exit__(None, None, None)
|
|
159
|
+
if self._session_context_manager is not None:
|
|
160
|
+
try:
|
|
161
|
+
self._finish_session_span(exitstatus=None)
|
|
162
|
+
finally:
|
|
163
|
+
self._session_context_manager.__exit__(None, None, None)
|
|
164
|
+
self._session_context_manager = None
|
|
165
|
+
self._session_span = None
|
|
166
|
+
self._session = None
|
|
167
|
+
if self._telemetry is not None:
|
|
168
|
+
try:
|
|
169
|
+
self._telemetry.flush()
|
|
170
|
+
except Exception:
|
|
171
|
+
logger.debug("Failed to flush Pytest telemetry", exc_info=True)
|
|
172
|
+
self._is_instrumented = False
|
|
173
|
+
logger.info("Pytest instrumentation deactivated")
|
|
174
|
+
|
|
175
|
+
def _resolved_workflow_name(self, config: Any) -> str:
|
|
176
|
+
if self._workflow_name:
|
|
177
|
+
return _safe_name(self._workflow_name)
|
|
178
|
+
rootpath = getattr(config, "rootpath", Path.cwd())
|
|
179
|
+
root_name = Path(rootpath).name or "tests"
|
|
180
|
+
return _safe_name(f"pytest_{root_name}_workflow")
|
|
181
|
+
|
|
182
|
+
def _metadata(self, *, worker_id: str | None = None) -> str:
|
|
183
|
+
metadata = {
|
|
184
|
+
"integration": "pytest",
|
|
185
|
+
"workflow_name": self._workflow_name,
|
|
186
|
+
"example_run_id": self._run_id,
|
|
187
|
+
}
|
|
188
|
+
if worker_id:
|
|
189
|
+
metadata["worker_id"] = worker_id
|
|
190
|
+
return _json_dumps(metadata, max_chars=self._max_attribute_chars)
|
|
191
|
+
|
|
192
|
+
def _set_common_attributes(
|
|
193
|
+
self,
|
|
194
|
+
span: Any,
|
|
195
|
+
*,
|
|
196
|
+
log_type: str,
|
|
197
|
+
entity_name: str,
|
|
198
|
+
entity_path: str,
|
|
199
|
+
input_payload: dict[str, Any],
|
|
200
|
+
worker_id: str | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
span.set_attribute(RESPAN_LOG_TYPE, log_type)
|
|
203
|
+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, entity_name)
|
|
204
|
+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_PATH, entity_path)
|
|
205
|
+
span.set_attribute(
|
|
206
|
+
SpanAttributes.TRACELOOP_ENTITY_INPUT,
|
|
207
|
+
_json_dumps(input_payload, max_chars=self._max_attribute_chars),
|
|
208
|
+
)
|
|
209
|
+
if self._workflow_name:
|
|
210
|
+
span.set_attribute(RESPAN_TRACE_GROUP_ID, self._workflow_name)
|
|
211
|
+
span.set_attribute(RESPAN_METADATA, self._metadata(worker_id=worker_id))
|
|
212
|
+
|
|
213
|
+
def pytest_sessionstart(self, session: Any) -> None:
|
|
214
|
+
if not self._is_instrumented or self._session_span is not None:
|
|
215
|
+
return
|
|
216
|
+
try:
|
|
217
|
+
self._session = session
|
|
218
|
+
self._workflow_name = self._resolved_workflow_name(session.config)
|
|
219
|
+
worker_id = os.getenv("PYTEST_XDIST_WORKER")
|
|
220
|
+
self._session_context_manager = self._tracer.start_as_current_span(
|
|
221
|
+
"pytest.session", kind=SpanKind.INTERNAL
|
|
222
|
+
)
|
|
223
|
+
self._session_span = self._session_context_manager.__enter__()
|
|
224
|
+
self._set_common_attributes(
|
|
225
|
+
self._session_span,
|
|
226
|
+
log_type=WORKFLOW_LOG_TYPE,
|
|
227
|
+
entity_name=self._workflow_name,
|
|
228
|
+
entity_path="",
|
|
229
|
+
input_payload={
|
|
230
|
+
"rootpath": str(getattr(session.config, "rootpath", "")),
|
|
231
|
+
"arguments": (
|
|
232
|
+
list(getattr(session.config, "args", ()) or ())
|
|
233
|
+
if self._capture_content
|
|
234
|
+
else []
|
|
235
|
+
),
|
|
236
|
+
"content_captured": self._capture_content,
|
|
237
|
+
},
|
|
238
|
+
worker_id=worker_id,
|
|
239
|
+
)
|
|
240
|
+
self._session_span.set_attribute(
|
|
241
|
+
RESPAN_SPAN_CUSTOM_ID,
|
|
242
|
+
f"{self._workflow_name}-{self._run_id}"
|
|
243
|
+
+ (f"-{worker_id}" if worker_id else ""),
|
|
244
|
+
)
|
|
245
|
+
except Exception:
|
|
246
|
+
logger.exception("Failed to start Pytest session span")
|
|
247
|
+
self._session_context_manager = None
|
|
248
|
+
self._session_span = None
|
|
249
|
+
|
|
250
|
+
def pytest_collection_finish(self, session: Any) -> None:
|
|
251
|
+
if self._session_span is None:
|
|
252
|
+
return
|
|
253
|
+
items = list(getattr(session, "items", ()) or ())
|
|
254
|
+
payload: dict[str, Any] = {
|
|
255
|
+
"rootpath": str(getattr(session.config, "rootpath", "")),
|
|
256
|
+
"collected": len(items),
|
|
257
|
+
"content_captured": self._capture_content,
|
|
258
|
+
}
|
|
259
|
+
if self._capture_content:
|
|
260
|
+
payload["tests"] = [getattr(item, "nodeid", str(item)) for item in items]
|
|
261
|
+
self._session_span.set_attribute(
|
|
262
|
+
SpanAttributes.TRACELOOP_ENTITY_INPUT,
|
|
263
|
+
_json_dumps(payload, max_chars=self._max_attribute_chars),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _test_nodeid(self, item: Any) -> str:
|
|
267
|
+
nodeid = str(item.nodeid)
|
|
268
|
+
return nodeid if self._capture_content else nodeid.split("[", 1)[0]
|
|
269
|
+
|
|
270
|
+
def _test_input(self, item: Any) -> dict[str, Any]:
|
|
271
|
+
payload: dict[str, Any] = {
|
|
272
|
+
"nodeid": self._test_nodeid(item),
|
|
273
|
+
"content_captured": self._capture_content,
|
|
274
|
+
}
|
|
275
|
+
if not self._capture_content:
|
|
276
|
+
return payload
|
|
277
|
+
callspec = getattr(item, "callspec", None)
|
|
278
|
+
params = getattr(callspec, "params", None)
|
|
279
|
+
if params:
|
|
280
|
+
payload["parameters"] = params
|
|
281
|
+
fixture_names = list(getattr(item, "fixturenames", ()) or ())
|
|
282
|
+
if fixture_names:
|
|
283
|
+
payload["fixtures"] = fixture_names
|
|
284
|
+
iter_markers = getattr(item, "iter_markers", None)
|
|
285
|
+
if callable(iter_markers):
|
|
286
|
+
markers = sorted({marker.name for marker in iter_markers()})
|
|
287
|
+
if markers:
|
|
288
|
+
payload["markers"] = markers
|
|
289
|
+
return payload
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Runtime Pytest hooks built on the public instrumentation lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from opentelemetry.semconv_ai import SpanAttributes
|
|
11
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
12
|
+
|
|
13
|
+
from respan_instrumentation_pytest._constants import TASK_LOG_TYPE
|
|
14
|
+
from respan_instrumentation_pytest._instrumentation import (
|
|
15
|
+
PytestInstrumentor,
|
|
16
|
+
_TestState,
|
|
17
|
+
_json_dumps,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PytestRuntimePlugin(PytestInstrumentor):
|
|
24
|
+
"""Pytest hook implementation registered by the `pytest11` entry point."""
|
|
25
|
+
|
|
26
|
+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
|
27
|
+
def pytest_runtest_protocol(self, item: Any, nextitem: Any):
|
|
28
|
+
if not self._is_instrumented or self._session_span is None:
|
|
29
|
+
yield
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
context_manager = None
|
|
33
|
+
state = None
|
|
34
|
+
try:
|
|
35
|
+
context_manager = self._tracer.start_as_current_span(
|
|
36
|
+
"pytest.test", kind=SpanKind.INTERNAL
|
|
37
|
+
)
|
|
38
|
+
span = context_manager.__enter__()
|
|
39
|
+
state = _TestState(span=span, context_manager=context_manager)
|
|
40
|
+
self._test_states[item.nodeid] = state
|
|
41
|
+
display_nodeid = self._test_nodeid(item)
|
|
42
|
+
self._set_common_attributes(
|
|
43
|
+
span,
|
|
44
|
+
log_type=TASK_LOG_TYPE,
|
|
45
|
+
entity_name=(
|
|
46
|
+
getattr(item, "name", item.nodeid)
|
|
47
|
+
if self._capture_content
|
|
48
|
+
else display_nodeid.rsplit("::", 1)[-1]
|
|
49
|
+
),
|
|
50
|
+
entity_path=display_nodeid,
|
|
51
|
+
input_payload=self._test_input(item),
|
|
52
|
+
worker_id=os.getenv("PYTEST_XDIST_WORKER"),
|
|
53
|
+
)
|
|
54
|
+
except Exception:
|
|
55
|
+
logger.exception("Failed to start Pytest test span for %s", item.nodeid)
|
|
56
|
+
if context_manager is not None:
|
|
57
|
+
context_manager.__exit__(None, None, None)
|
|
58
|
+
yield
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
try:
|
|
65
|
+
self._finish_test_span(item.nodeid, state)
|
|
66
|
+
except Exception:
|
|
67
|
+
logger.exception(
|
|
68
|
+
"Failed to finish Pytest test span for %s", item.nodeid
|
|
69
|
+
)
|
|
70
|
+
finally:
|
|
71
|
+
self._test_states.pop(item.nodeid, None)
|
|
72
|
+
context_manager.__exit__(None, None, None)
|
|
73
|
+
|
|
74
|
+
@pytest.hookimpl(hookwrapper=True, trylast=True)
|
|
75
|
+
def pytest_runtest_makereport(self, item: Any, call: Any):
|
|
76
|
+
outcome = yield
|
|
77
|
+
state = self._test_states.get(item.nodeid)
|
|
78
|
+
if state is None:
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
report = outcome.get_result()
|
|
82
|
+
state.reports[report.when] = report
|
|
83
|
+
state.durations[report.when] = float(getattr(report, "duration", 0.0))
|
|
84
|
+
if report.failed:
|
|
85
|
+
excinfo = getattr(call, "excinfo", None)
|
|
86
|
+
exception = getattr(excinfo, "value", None)
|
|
87
|
+
if isinstance(exception, BaseException):
|
|
88
|
+
state.error = exception
|
|
89
|
+
state.error_when = report.when
|
|
90
|
+
if isinstance(exception, Exception) and self._capture_content:
|
|
91
|
+
state.span.record_exception(exception)
|
|
92
|
+
message = (
|
|
93
|
+
str(exception)
|
|
94
|
+
if self._capture_content
|
|
95
|
+
else type(exception).__name__
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
state.error_when = report.when
|
|
99
|
+
message = "Pytest phase failed"
|
|
100
|
+
state.span.set_status(Status(StatusCode.ERROR, message))
|
|
101
|
+
state.span.set_attribute("status_code", 500)
|
|
102
|
+
state.span.set_attribute("error.message", message)
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.exception("Failed to process Pytest report for %s", item.nodeid)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _test_outcome(state: _TestState) -> str:
|
|
108
|
+
if any(getattr(report, "failed", False) for report in state.reports.values()):
|
|
109
|
+
return "failed"
|
|
110
|
+
if any(getattr(report, "skipped", False) for report in state.reports.values()):
|
|
111
|
+
if any(hasattr(report, "wasxfail") for report in state.reports.values()):
|
|
112
|
+
return "xfailed"
|
|
113
|
+
return "skipped"
|
|
114
|
+
return "passed"
|
|
115
|
+
|
|
116
|
+
def _finish_test_span(self, nodeid: str, state: _TestState) -> None:
|
|
117
|
+
outcome = self._test_outcome(state)
|
|
118
|
+
self._outcome_counts[outcome] += 1
|
|
119
|
+
phases = {
|
|
120
|
+
phase: {
|
|
121
|
+
"outcome": getattr(report, "outcome", "unknown"),
|
|
122
|
+
"duration_seconds": state.durations.get(phase, 0.0),
|
|
123
|
+
}
|
|
124
|
+
for phase, report in state.reports.items()
|
|
125
|
+
}
|
|
126
|
+
output: dict[str, Any] = {
|
|
127
|
+
"outcome": outcome,
|
|
128
|
+
"phases": phases,
|
|
129
|
+
"duration_seconds": sum(state.durations.values()),
|
|
130
|
+
"content_captured": self._capture_content,
|
|
131
|
+
}
|
|
132
|
+
if state.error is not None:
|
|
133
|
+
error = {
|
|
134
|
+
"type": type(state.error).__name__,
|
|
135
|
+
"phase": state.error_when,
|
|
136
|
+
}
|
|
137
|
+
if self._capture_content:
|
|
138
|
+
error["message"] = str(state.error)
|
|
139
|
+
output["error"] = error
|
|
140
|
+
if outcome != "failed":
|
|
141
|
+
state.span.set_attribute("status_code", 200)
|
|
142
|
+
state.span.set_attribute(
|
|
143
|
+
SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
|
|
144
|
+
_json_dumps(output, max_chars=self._max_attribute_chars),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _finish_session_span(self, exitstatus: Any) -> None:
|
|
148
|
+
if self._session_span is None:
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
numeric_status = int(exitstatus) if exitstatus is not None else None
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
numeric_status = None
|
|
154
|
+
failed = numeric_status is not None and numeric_status not in {0, 5}
|
|
155
|
+
output = {
|
|
156
|
+
"exit_status": numeric_status,
|
|
157
|
+
"outcomes": dict(self._outcome_counts),
|
|
158
|
+
"total": sum(self._outcome_counts.values()),
|
|
159
|
+
}
|
|
160
|
+
self._session_span.set_attribute(
|
|
161
|
+
SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
|
|
162
|
+
_json_dumps(output, max_chars=self._max_attribute_chars),
|
|
163
|
+
)
|
|
164
|
+
if failed:
|
|
165
|
+
message = f"Pytest exited with status {numeric_status}"
|
|
166
|
+
self._session_span.set_status(Status(StatusCode.ERROR, message))
|
|
167
|
+
self._session_span.set_attribute("status_code", 500)
|
|
168
|
+
self._session_span.set_attribute("error.message", message)
|
|
169
|
+
else:
|
|
170
|
+
self._session_span.set_attribute("status_code", 200)
|
|
171
|
+
|
|
172
|
+
def pytest_sessionfinish(self, session: Any, exitstatus: Any) -> None:
|
|
173
|
+
if self._session_context_manager is None:
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
self._finish_session_span(exitstatus)
|
|
177
|
+
finally:
|
|
178
|
+
self._session_context_manager.__exit__(None, None, None)
|
|
179
|
+
self._session_context_manager = None
|
|
180
|
+
self._session_span = None
|
|
181
|
+
if self._telemetry is not None:
|
|
182
|
+
try:
|
|
183
|
+
self._telemetry.flush()
|
|
184
|
+
except Exception:
|
|
185
|
+
logger.debug("Failed to flush Pytest telemetry", exc_info=True)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Pytest entry-point module for Respan test tracing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from respan_instrumentation_pytest._constants import (
|
|
9
|
+
ENV_CAPTURE_CONTENT,
|
|
10
|
+
ENV_ENABLED,
|
|
11
|
+
ENV_WORKFLOW_NAME,
|
|
12
|
+
PYTEST_RUNTIME_PLUGIN_NAME,
|
|
13
|
+
)
|
|
14
|
+
from respan_instrumentation_pytest._runtime import PytestRuntimePlugin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
18
|
+
value = os.getenv(name)
|
|
19
|
+
if value is None:
|
|
20
|
+
return default
|
|
21
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pytest_addoption(parser: Any) -> None:
|
|
25
|
+
group = parser.getgroup("respan")
|
|
26
|
+
group.addoption(
|
|
27
|
+
"--respan-tracing",
|
|
28
|
+
action="store_true",
|
|
29
|
+
default=None,
|
|
30
|
+
help="Emit Respan workflow/task spans for the Pytest session and tests.",
|
|
31
|
+
)
|
|
32
|
+
group.addoption(
|
|
33
|
+
"--respan-capture-content",
|
|
34
|
+
action="store_true",
|
|
35
|
+
dest="respan_capture_content",
|
|
36
|
+
default=None,
|
|
37
|
+
help="Capture test parameters and failure messages in Respan spans.",
|
|
38
|
+
)
|
|
39
|
+
group.addoption(
|
|
40
|
+
"--no-respan-capture-content",
|
|
41
|
+
action="store_false",
|
|
42
|
+
dest="respan_capture_content",
|
|
43
|
+
default=None,
|
|
44
|
+
help="Omit test parameters and failure messages from Respan spans.",
|
|
45
|
+
)
|
|
46
|
+
parser.addini(
|
|
47
|
+
"respan_tracing", "Enable Respan Pytest tracing.", type="bool", default=False
|
|
48
|
+
)
|
|
49
|
+
parser.addini(
|
|
50
|
+
"respan_capture_content",
|
|
51
|
+
"Capture test parameters and failure messages.",
|
|
52
|
+
type="bool",
|
|
53
|
+
default=True,
|
|
54
|
+
)
|
|
55
|
+
parser.addini(
|
|
56
|
+
"respan_workflow_name",
|
|
57
|
+
"Workflow name used for the Pytest session trace.",
|
|
58
|
+
default="",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _enabled(config: Any) -> bool:
|
|
63
|
+
cli_value = config.getoption("respan_tracing")
|
|
64
|
+
if cli_value is not None:
|
|
65
|
+
return bool(cli_value)
|
|
66
|
+
if _env_bool(ENV_ENABLED, False):
|
|
67
|
+
return True
|
|
68
|
+
return bool(config.getini("respan_tracing"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _capture_content(config: Any) -> bool:
|
|
72
|
+
cli_value = config.getoption("respan_capture_content")
|
|
73
|
+
if cli_value is not None:
|
|
74
|
+
return bool(cli_value)
|
|
75
|
+
if os.getenv(ENV_CAPTURE_CONTENT) is not None:
|
|
76
|
+
return _env_bool(ENV_CAPTURE_CONTENT, True)
|
|
77
|
+
return bool(config.getini("respan_capture_content"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _workflow_name(config: Any) -> str | None:
|
|
81
|
+
return os.getenv(ENV_WORKFLOW_NAME) or config.getini("respan_workflow_name") or None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pytest_configure(config: Any) -> None:
|
|
85
|
+
if not _enabled(config):
|
|
86
|
+
return
|
|
87
|
+
pluginmanager = config.pluginmanager
|
|
88
|
+
if pluginmanager.get_plugin(PYTEST_RUNTIME_PLUGIN_NAME) is not None:
|
|
89
|
+
return
|
|
90
|
+
instrumentor = PytestRuntimePlugin(
|
|
91
|
+
capture_content=_capture_content(config),
|
|
92
|
+
workflow_name=_workflow_name(config),
|
|
93
|
+
)
|
|
94
|
+
instrumentor.activate()
|
|
95
|
+
pluginmanager.register(instrumentor, PYTEST_RUNTIME_PLUGIN_NAME)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def pytest_unconfigure(config: Any) -> None:
|
|
99
|
+
pluginmanager = config.pluginmanager
|
|
100
|
+
instrumentor = pluginmanager.get_plugin(PYTEST_RUNTIME_PLUGIN_NAME)
|
|
101
|
+
if instrumentor is None:
|
|
102
|
+
return
|
|
103
|
+
instrumentor.deactivate()
|
|
104
|
+
pluginmanager.unregister(instrumentor)
|