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.
@@ -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,5 @@
1
+ from respan_instrumentation_pytest._runtime import (
2
+ PytestRuntimePlugin as PytestInstrumentor,
3
+ )
4
+
5
+ __all__ = ["PytestInstrumentor"]
@@ -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)