Skip to content

Commit 34f7907

Browse files
authored
Add trace-per-test option (#34)
Trace-per-run is not always great e.g. it's confusing to have a sequence of tests which are normally independent, and then the layout changes when xdist is involved as you get concurrent spans (one for each worker). This also causes issues in the more bare-bone / simplistic frontends / final collectors (e.g. OpenTelemetry Desktop) as they might not have the most complex folding and filtering features. OTD for instance has little to no ability to manipulate spans, but it is possible to select individual traces and see just that trace's spans (in fact that's the only mode). As a result, generating a separate trace per test per run allows easier classification, observation, and manipulation of the trace. I would also assume in the long term it allows comparing the traces of the same test in order to get insight into their evolution, something which is more difficult with trace-per-run. Finally an other issue with trace-per-run, mostly in long suites, is that the trace remains incomplete (tools complain of missing parent spans) until the entire run has completed, making observation during run more difficult.
1 parent 05cfc44 commit 34f7907

File tree

4 files changed

+124
-43
lines changed

4 files changed

+124
-43
lines changed

src/pytest_opentelemetry/instrumentation.py

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
2-
from typing import Any, Dict, Generator, Optional, Union
2+
from typing import Any, Dict, Iterator, Optional, Union
33

44
import pytest
55
from _pytest.config import Config
@@ -23,20 +23,12 @@
2323
tracer = trace.get_tracer('pytest-opentelemetry')
2424

2525

26-
class OpenTelemetryPlugin:
27-
"""A pytest plugin which produces OpenTelemetry spans around test sessions and
28-
individual test runs."""
26+
class PerTestOpenTelemetryPlugin:
27+
"""base logic for all otel pytest integration"""
2928

3029
@property
31-
def session_name(self):
32-
# Lazy initialise session name
33-
if not hasattr(self, '_session_name'):
34-
self._session_name = os.environ.get('PYTEST_RUN_NAME', 'test run')
35-
return self._session_name
36-
37-
@session_name.setter
38-
def session_name(self, name):
39-
self._session_name = name
30+
def item_parent(self) -> Union[str, None]:
31+
return self.trace_parent
4032

4133
@classmethod
4234
def get_trace_parent(cls, config: Config) -> Optional[Context]:
@@ -71,22 +63,7 @@ def pytest_configure(self, config: Config) -> None:
7163
configurator.resource_detectors.append(OTELResourceDetector())
7264
configurator.configure()
7365

74-
def pytest_sessionstart(self, session: Session) -> None:
75-
self.session_span = tracer.start_span(
76-
self.session_name,
77-
context=self.trace_parent,
78-
attributes={
79-
"pytest.span_type": "run",
80-
},
81-
)
82-
self.has_error = False
83-
8466
def pytest_sessionfinish(self, session: Session) -> None:
85-
self.session_span.set_status(
86-
StatusCode.ERROR if self.has_error else StatusCode.OK
87-
)
88-
89-
self.session_span.end()
9067
self.try_force_flush()
9168

9269
def _attributes_from_item(self, item: Item) -> Dict[str, Union[str, int]]:
@@ -103,17 +80,16 @@ def _attributes_from_item(self, item: Item) -> Dict[str, Union[str, int]]:
10380
return attributes
10481

10582
@pytest.hookimpl(hookwrapper=True)
106-
def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]:
107-
context = trace.set_span_in_context(self.session_span)
83+
def pytest_runtest_protocol(self, item: Item) -> Iterator[None]:
10884
with tracer.start_as_current_span(
10985
item.nodeid,
11086
attributes=self._attributes_from_item(item),
111-
context=context,
87+
context=self.item_parent,
11288
):
11389
yield
11490

11591
@pytest.hookimpl(hookwrapper=True)
116-
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
92+
def pytest_runtest_setup(self, item: Item) -> Iterator[None]:
11793
with tracer.start_as_current_span(
11894
f'{item.nodeid}::setup',
11995
attributes=self._attributes_from_item(item),
@@ -145,23 +121,23 @@ def _name_from_fixturedef(self, fixturedef: FixtureDef, request: FixtureRequest)
145121
@pytest.hookimpl(hookwrapper=True)
146122
def pytest_fixture_setup(
147123
self, fixturedef: FixtureDef, request: FixtureRequest
148-
) -> Generator[None, None, None]:
124+
) -> Iterator[None]:
149125
with tracer.start_as_current_span(
150126
name=f'{self._name_from_fixturedef(fixturedef, request)} setup',
151127
attributes=self._attributes_from_fixturedef(fixturedef),
152128
):
153129
yield
154130

155131
@pytest.hookimpl(hookwrapper=True)
156-
def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
132+
def pytest_runtest_call(self, item: Item) -> Iterator[None]:
157133
with tracer.start_as_current_span(
158134
name=f'{item.nodeid}::call',
159135
attributes=self._attributes_from_item(item),
160136
):
161137
yield
162138

163139
@pytest.hookimpl(hookwrapper=True)
164-
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
140+
def pytest_runtest_teardown(self, item: Item) -> Iterator[None]:
165141
with tracer.start_as_current_span(
166142
name=f'{item.nodeid}::teardown',
167143
attributes=self._attributes_from_item(item),
@@ -188,7 +164,7 @@ def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
188164
@pytest.hookimpl(hookwrapper=True)
189165
def pytest_fixture_post_finalizer(
190166
self, fixturedef: FixtureDef, request: SubRequest
191-
) -> Generator[None, None, None]:
167+
) -> Iterator[None]:
192168
"""When the span for a fixture teardown is created by
193169
pytest_runtest_teardown or a previous pytest_fixture_post_finalizer, we
194170
need to update the name and attributes now that we know which fixture it
@@ -250,10 +226,52 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
250226

251227
has_error = report.outcome == 'failed'
252228
status_code = StatusCode.ERROR if has_error else StatusCode.OK
253-
self.has_error |= has_error
254229
trace.get_current_span().set_status(status_code)
255230

256231

232+
class OpenTelemetryPlugin(PerTestOpenTelemetryPlugin):
233+
"""A pytest plugin which produces OpenTelemetry spans around test sessions and
234+
individual test runs."""
235+
236+
@property
237+
def session_name(self):
238+
# Lazy initialise session name
239+
if not hasattr(self, '_session_name'):
240+
self._session_name = os.environ.get('PYTEST_RUN_NAME', 'test run')
241+
return self._session_name
242+
243+
@session_name.setter
244+
def session_name(self, name):
245+
self._session_name = name
246+
247+
@property
248+
def item_parent(self) -> Union[str, None]:
249+
context = trace.set_span_in_context(self.session_span)
250+
return context
251+
252+
def pytest_sessionstart(self, session: Session) -> None:
253+
self.session_span = tracer.start_span(
254+
self.session_name,
255+
context=self.trace_parent,
256+
attributes={
257+
"pytest.span_type": "run",
258+
},
259+
)
260+
self.has_error = False
261+
262+
def pytest_sessionfinish(self, session: Session) -> None:
263+
self.session_span.set_status(
264+
StatusCode.ERROR if self.has_error else StatusCode.OK
265+
)
266+
267+
self.session_span.end()
268+
super().pytest_sessionfinish(session)
269+
270+
def pytest_runtest_logreport(self, report: TestReport) -> None:
271+
super().pytest_runtest_logreport(report)
272+
self.has_error |= report.when == 'call' and report.outcome == 'failed'
273+
274+
257275
try:
258276
from xdist.workermanage import WorkerController # pylint: disable=unused-import
259277
except ImportError: # pragma: no cover

src/pytest_opentelemetry/plugin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,25 @@ def pytest_addoption(parser: Parser) -> None:
2626
'trace. If it is omitted, this test run will start a new trace.'
2727
),
2828
)
29+
group.addoption(
30+
"--trace-per-test",
31+
action="store_true",
32+
default=False,
33+
help="Creates a separate trace per test instead of a trace for the test run",
34+
)
2935

3036

3137
def pytest_configure(config: Config) -> None:
3238
# pylint: disable=import-outside-toplevel
3339
from pytest_opentelemetry.instrumentation import (
3440
OpenTelemetryPlugin,
41+
PerTestOpenTelemetryPlugin,
3542
XdistOpenTelemetryPlugin,
3643
)
3744

38-
if config.pluginmanager.has_plugin("xdist"):
45+
if config.getvalue('--trace-per-test'):
46+
config.pluginmanager.register(PerTestOpenTelemetryPlugin())
47+
elif config.pluginmanager.has_plugin("xdist"):
3948
config.pluginmanager.register(XdistOpenTelemetryPlugin())
4049
else:
4150
config.pluginmanager.register(OpenTelemetryPlugin())

tests/test_sessions.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import os
22
from contextlib import contextmanager
3-
from typing import Dict, Generator, Optional
3+
from typing import Dict, Generator, List, Optional
44
from unittest.mock import Mock, patch
55

6+
import pytest
67
from _pytest.pytester import Pytester
78
from opentelemetry import trace
89

910
from pytest_opentelemetry.instrumentation import (
1011
OpenTelemetryPlugin,
12+
PerTestOpenTelemetryPlugin,
1113
XdistOpenTelemetryPlugin,
1214
)
1315

@@ -187,13 +189,50 @@ def test_two():
187189
result.assert_outcomes(passed=2)
188190

189191

192+
@pytest.mark.parametrize(
193+
'args',
194+
[
195+
pytest.param([], id="default"),
196+
pytest.param(['-n', '2'], id="xdist"),
197+
pytest.param(['-p', 'no:xdist'], id='no:xdist'),
198+
],
199+
)
200+
def test_trace_per_test(
201+
pytester: Pytester, span_recorder: SpanRecorder, args: List[str]
202+
) -> None:
203+
pytester.makepyfile(
204+
"""
205+
from opentelemetry import trace
206+
207+
def test_one():
208+
span = trace.get_current_span()
209+
assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef
210+
211+
def test_two():
212+
span = trace.get_current_span()
213+
assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef
214+
"""
215+
)
216+
result = pytester.runpytest_subprocess(
217+
'--trace-per-test',
218+
*args,
219+
'--trace-parent',
220+
'00-1234567890abcdef1234567890abcdef-fedcba0987654321-01',
221+
)
222+
result.assert_outcomes(passed=2)
223+
224+
190225
@patch.object(trace, 'get_tracer_provider')
191226
def test_force_flush_with_supported_provider(mock_get_tracer_provider):
192227
provider = Mock()
193228
provider.force_flush = Mock(return_value=None)
194229
mock_get_tracer_provider.return_value = provider
195230

196-
for plugin in OpenTelemetryPlugin, XdistOpenTelemetryPlugin:
231+
for plugin in (
232+
OpenTelemetryPlugin,
233+
XdistOpenTelemetryPlugin,
234+
PerTestOpenTelemetryPlugin,
235+
):
197236
assert plugin.try_force_flush() is True
198237

199238

@@ -202,5 +241,9 @@ def test_force_flush_with_unsupported_provider(mock_get_tracer_provider):
202241
provider = Mock(spec=trace.ProxyTracerProvider)
203242
mock_get_tracer_provider.return_value = provider
204243

205-
for plugin in OpenTelemetryPlugin, XdistOpenTelemetryPlugin:
244+
for plugin in (
245+
OpenTelemetryPlugin,
246+
XdistOpenTelemetryPlugin,
247+
PerTestOpenTelemetryPlugin,
248+
):
206249
assert plugin.try_force_flush() is False

tests/test_spans.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from collections import Counter
2+
from typing import List
23

4+
import pytest
35
from _pytest.pytester import Pytester
46
from opentelemetry.trace import SpanKind
57

@@ -412,8 +414,17 @@ def test_one(session_scoped: int, module_scoped: int, function_scoped: int):
412414
assert function_scoped.parent.span_id == setup.context.span_id
413415

414416

417+
@pytest.mark.parametrize(
418+
'args',
419+
[
420+
pytest.param([], id='trace-per-suite'),
421+
pytest.param(['--trace-per-test'], id='trace-per-test'),
422+
],
423+
)
415424
def test_spans_from_fixutres_used_multiple_times(
416-
pytester: Pytester, span_recorder: SpanRecorder
425+
pytester: Pytester,
426+
span_recorder: SpanRecorder,
427+
args: List[str],
417428
) -> None:
418429
pytester.makepyfile(
419430
"""
@@ -456,7 +467,7 @@ def test_c(self, class_scoped: int, function_scoped: int):
456467
457468
"""
458469
)
459-
pytester.runpytest().assert_outcomes(passed=5)
470+
pytester.runpytest(*args).assert_outcomes(passed=5)
460471
spans = Counter(span.name for span in span_recorder.finished_spans())
461472

462473
assert spans['session_scoped setup'] == 1

0 commit comments

Comments
 (0)