From 44b6e410b4abf256bc31f831e5c77b62072a8938 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Wed, 13 May 2026 20:41:25 -0400 Subject: [PATCH] fix: log test errors on spans Keep failed test span statuses low-cardinality while emitting assertion details as stderr logs. This preserves failure details in UIs without stuffing dynamic output into span status descriptions. Signed-off-by: Alex Suraci --- pytest_otel/src/pytest_otel/tracer.py | 4 +-- pytest_otel/tests/test_plugin.py | 27 ++++++++++++++++ pytest_otel/tests/test_tracer.py | 46 ++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/pytest_otel/src/pytest_otel/tracer.py b/pytest_otel/src/pytest_otel/tracer.py index 3a9b4b0..3cf2800 100644 --- a/pytest_otel/src/pytest_otel/tracer.py +++ b/pytest_otel/src/pytest_otel/tracer.py @@ -109,7 +109,7 @@ def end_session(self, exitstatus: int) -> None: if exitstatus == 0: span.set_status(Status(StatusCode.OK)) else: - span.set_status(Status(StatusCode.ERROR, f"Exit status: {exitstatus}")) + span.set_status(Status(StatusCode.ERROR, "test session failed")) span.end() @@ -178,7 +178,7 @@ def end_test(self, item: "pytest.Item", outcome: str) -> None: if outcome == "passed": span.set_status(Status(StatusCode.OK)) elif outcome in ("failed", "error"): - span.set_status(Status(StatusCode.ERROR, f"Test {outcome}")) + span.set_status(Status(StatusCode.ERROR, "test failed")) # "skipped" keeps UNSET status (neither OK nor ERROR) span.end() diff --git a/pytest_otel/tests/test_plugin.py b/pytest_otel/tests/test_plugin.py index b6f26fd..44155c4 100644 --- a/pytest_otel/tests/test_plugin.py +++ b/pytest_otel/tests/test_plugin.py @@ -115,3 +115,30 @@ def test_capture_stderr(self, monkeypatch): plugin._capture_test_output(mock_item, report) assert emitted == [("test stderr", STDIO_STREAM_STDERR, mock_span)] + + def test_capture_failure_details(self, monkeypatch): + """Test that assertion details are captured as stderr logs.""" + from pytest_otel import plugin + from pytest_otel.logging_handler import STDIO_STREAM_STDERR + + mock_item = Mock() + mock_item.nodeid = "tests/test_example.py::test_function" + mock_span = Mock() + emitted = [] + + monkeypatch.setattr(plugin.tracer, "get_test_span", lambda item: mock_span) + monkeypatch.setattr( + "pytest_otel.logging_handler.emit_stdio_log", + lambda body, stream, span=None: emitted.append((body, stream, span)), + ) + + report = Mock() + report.when = "call" + report.failed = True + report.capstdout = None + report.capstderr = None + report.longrepr = "assert 1 == 2" + + plugin._capture_test_output(mock_item, report) + + assert emitted == [("assert 1 == 2", STDIO_STREAM_STDERR, mock_span)] diff --git a/pytest_otel/tests/test_tracer.py b/pytest_otel/tests/test_tracer.py index 9aed115..232efbd 100644 --- a/pytest_otel/tests/test_tracer.py +++ b/pytest_otel/tests/test_tracer.py @@ -1,6 +1,10 @@ """Tests for the tracer module.""" -from pytest_otel.tracer import SpanContextManager +from unittest.mock import Mock + +from opentelemetry.trace import StatusCode + +from pytest_otel.tracer import SpanContextManager, TestNode as OtelTestNode class TestParseNodeid: @@ -84,6 +88,46 @@ def test_suite_run_status_mapping(self): assert ctx._suite_run_status(5) == TestSuiteRunStatusValues.SKIPPED.value +class TestStatusDescriptions: + """Tests for low-cardinality span status descriptions.""" + + def test_session_failure_status_is_static(self): + """Verify session failures do not include dynamic exit status text.""" + ctx = SpanContextManager() + span = Mock() + ctx._session_node = OtelTestNode( + nodeid="session", + name="pytest session", + kind="session", + span=span, + ) + + ctx.end_session(1) + + status = span.set_status.call_args.args[0] + assert status.status_code == StatusCode.ERROR + assert status.description == "test session failed" + + def test_test_failure_status_is_static(self): + """Verify test failures do not include dynamic assertion text.""" + ctx = SpanContextManager() + span = Mock() + item = Mock() + item.nodeid = "tests/test_foo.py::test_bar" + ctx._tests[item.nodeid] = OtelTestNode( + nodeid=item.nodeid, + name="test_bar", + kind="function", + span=span, + ) + + ctx.end_test(item, "failed") + + status = span.set_status.call_args.args[0] + assert status.status_code == StatusCode.ERROR + assert status.description == "test failed" + + class TestAttributeNames: """Tests for attribute name constants."""