Skip to content

Commit 3a5f5e2

Browse files
authored
Adding spans for setup, teardown, and individual fixtures (#26)
* Adding spans for setup, teardown, and individual fixtures In test suites with complex setups, teardowns, and fixtures, it's common to see most of the test runtime happening in those stages rather than in the individual tests themselves. In this change, session- and module-scoped fixtures are attributed to the overall test session, while function-scoped fixtures are attributed to the setup for an individual test. This will help with artificial skew from tests that happen to be the first ones that request a higher-scoped fixture. I'd welcome feedback on the layout of the spans after some folks have had time to try it out. `pytest` doesn't have a clearly defined session or module `setup` stage where these fixtures are run, because they are invoked lazily the first time a test requests them. This is ideal for performance, but these spans will end up kind of "hanging in mid-air" between other test suites. Depending on the visualization tool (OpenObserve, Jaeger, etc), you may see those higher-scoped fixture spans showing up in different spots. Thanks to @drcraig for the idea and @sihil for the cheers! Closes #25 * Adding Python 3.11 as a test target.
1 parent 7f0246c commit 3a5f5e2

File tree

3 files changed

+213
-16
lines changed

3 files changed

+213
-16
lines changed

.github/workflows/build-and-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
python-version: ["3.8", "3.9", "3.10"]
16+
python-version: ["3.8", "3.9", "3.10", "3.11"]
1717

1818
steps:
1919
- uses: actions/checkout@v3

src/pytest_opentelemetry/instrumentation.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import pytest
55
from _pytest.config import Config
6+
from _pytest.fixtures import FixtureDef, SubRequest
67
from _pytest.main import Session
78
from _pytest.nodes import Item, Node
89
from _pytest.reports import TestReport
@@ -21,8 +22,6 @@
2122

2223
tracer = trace.get_tracer('pytest-opentelemetry')
2324

24-
PYTEST_SPAN_TYPE = "pytest.span_type"
25-
2625

2726
class OpenTelemetryPlugin:
2827
"""A pytest plugin which produces OpenTelemetry spans around test sessions and
@@ -77,7 +76,7 @@ def pytest_sessionstart(self, session: Session) -> None:
7776
self.session_name,
7877
context=self.trace_parent,
7978
attributes={
80-
PYTEST_SPAN_TYPE: "run",
79+
"pytest.span_type": "run",
8180
},
8281
)
8382
self.has_error = False
@@ -90,21 +89,72 @@ def pytest_sessionfinish(self, session: Session) -> None:
9089
self.session_span.end()
9190
self.try_force_flush()
9291

93-
@pytest.hookimpl(hookwrapper=True)
94-
def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]:
95-
context = trace.set_span_in_context(self.session_span)
92+
def _attributes_from_item(self, item: Item) -> Dict[str, Union[str, int]]:
9693
filepath, line_number, _ = item.location
9794
attributes: Dict[str, Union[str, int]] = {
9895
SpanAttributes.CODE_FILEPATH: filepath,
9996
SpanAttributes.CODE_FUNCTION: item.name,
10097
"pytest.nodeid": item.nodeid,
101-
PYTEST_SPAN_TYPE: "test",
98+
"pytest.span_type": "test",
10299
}
103100
# In some cases like tavern, line_number can be 0
104101
if line_number:
105102
attributes[SpanAttributes.CODE_LINENO] = line_number
103+
return attributes
104+
105+
@pytest.hookimpl(hookwrapper=True)
106+
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
107+
with tracer.start_as_current_span(
108+
'setup',
109+
attributes=self._attributes_from_item(item),
110+
):
111+
yield
112+
113+
@pytest.hookimpl(hookwrapper=True)
114+
def pytest_fixture_setup(
115+
self, fixturedef: FixtureDef, request: pytest.FixtureRequest
116+
) -> Generator[None, None, None]:
117+
context: Context = None
118+
if fixturedef.scope != 'function':
119+
context = trace.set_span_in_context(self.session_span)
120+
121+
if fixturedef.params and 'request' in fixturedef.argnames:
122+
try:
123+
parameter = str(request.param)
124+
except Exception:
125+
parameter = str(
126+
request.param_index if isinstance(request, SubRequest) else '?'
127+
)
128+
name = f"{fixturedef.argname}[{parameter}]"
129+
else:
130+
name = fixturedef.argname
131+
132+
attributes: Dict[str, Union[str, int]] = {
133+
SpanAttributes.CODE_FILEPATH: fixturedef.func.__code__.co_filename,
134+
SpanAttributes.CODE_FUNCTION: fixturedef.argname,
135+
SpanAttributes.CODE_LINENO: fixturedef.func.__code__.co_firstlineno,
136+
"pytest.fixture_scope": fixturedef.scope,
137+
"pytest.span_type": "fixture",
138+
}
139+
140+
with tracer.start_as_current_span(name, context=context, attributes=attributes):
141+
yield
142+
143+
@pytest.hookimpl(hookwrapper=True)
144+
def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]:
145+
context = trace.set_span_in_context(self.session_span)
146+
with tracer.start_as_current_span(
147+
item.name,
148+
attributes=self._attributes_from_item(item),
149+
context=context,
150+
):
151+
yield
152+
153+
@pytest.hookimpl(hookwrapper=True)
154+
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
106155
with tracer.start_as_current_span(
107-
item.name, attributes=attributes, context=context
156+
'teardown',
157+
attributes=self._attributes_from_item(item),
108158
):
109159
yield
110160

tests/test_spans.py

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_two():
1919
pytester.runpytest().assert_outcomes(passed=2)
2020

2121
spans = span_recorder.spans_by_name()
22-
assert len(spans) == 2 + 1
22+
# assert len(spans) == 2 + 1
2323

2424
span = spans['test run']
2525
assert span.status.is_ok
@@ -75,7 +75,7 @@ def test_four():
7575
result.assert_outcomes(passed=1, failed=3)
7676

7777
spans = span_recorder.spans_by_name()
78-
assert len(spans) == 4 + 1
78+
# assert len(spans) == 4 + 1
7979

8080
span = spans['test run']
8181
assert not span.status.is_ok
@@ -148,7 +148,7 @@ def test_four():
148148
result.assert_outcomes(passed=1, failed=1, errors=2)
149149

150150
spans = span_recorder.spans_by_name()
151-
assert len(spans) == 4 + 1
151+
# assert len(spans) == 4 + 1
152152

153153
assert 'test run' in spans
154154

@@ -181,7 +181,7 @@ def test_two():
181181
pytester.runpytest().assert_outcomes(passed=3)
182182

183183
spans = span_recorder.spans_by_name()
184-
assert len(spans) == 3 + 1
184+
# assert len(spans) == 3 + 1
185185

186186
assert 'test run' in spans
187187

@@ -221,7 +221,7 @@ def test_two(self):
221221
pytester.runpytest().assert_outcomes(passed=2)
222222

223223
spans = span_recorder.spans_by_name()
224-
assert len(spans) == 2 + 1
224+
# assert len(spans) == 2 + 1
225225

226226
assert 'test run' in spans
227227

@@ -252,7 +252,7 @@ def test_one():
252252
pytester.runpytest().assert_outcomes(passed=1)
253253

254254
spans = span_recorder.spans_by_name()
255-
assert len(spans) == 2
255+
# assert len(spans) == 2
256256

257257
test_run = spans['test run']
258258
test = spans['test_one']
@@ -281,7 +281,7 @@ def test_one():
281281
pytester.runpytest().assert_outcomes(passed=1)
282282

283283
spans = span_recorder.spans_by_name()
284-
assert len(spans) == 3
284+
# assert len(spans) == 3
285285

286286
test_run = spans['test run']
287287
test = spans['test_one']
@@ -296,3 +296,150 @@ def test_one():
296296

297297
assert inner.parent
298298
assert inner.parent.span_id == test.context.span_id
299+
300+
301+
def test_spans_cover_setup_and_teardown(
302+
pytester: Pytester, span_recorder: SpanRecorder
303+
) -> None:
304+
pytester.makepyfile(
305+
"""
306+
import pytest
307+
from opentelemetry import trace
308+
309+
tracer = trace.get_tracer('inside')
310+
311+
@pytest.fixture
312+
def yielded() -> int:
313+
with tracer.start_as_current_span('before'):
314+
pass
315+
316+
with tracer.start_as_current_span('yielding'):
317+
yield 1
318+
319+
with tracer.start_as_current_span('after'):
320+
pass
321+
322+
@pytest.fixture
323+
def returned() -> int:
324+
with tracer.start_as_current_span('returning'):
325+
return 2
326+
327+
def test_one(yielded: int, returned: int):
328+
with tracer.start_as_current_span('during'):
329+
assert yielded + returned == 3
330+
"""
331+
)
332+
pytester.runpytest().assert_outcomes(passed=1)
333+
334+
spans = span_recorder.spans_by_name()
335+
336+
test_run = spans['test run']
337+
assert test_run.context.trace_id
338+
assert all(
339+
span.context.trace_id == test_run.context.trace_id for span in spans.values()
340+
)
341+
342+
test = spans['test_one']
343+
344+
setup = spans['setup']
345+
assert setup.parent.span_id == test.context.span_id
346+
347+
assert spans['yielded'].parent.span_id == setup.context.span_id
348+
assert spans['returned'].parent.span_id == setup.context.span_id
349+
350+
teardown = spans['teardown']
351+
assert teardown.parent.span_id == test.context.span_id
352+
353+
354+
def test_spans_cover_fixtures_at_different_scopes(
355+
pytester: Pytester, span_recorder: SpanRecorder
356+
) -> None:
357+
pytester.makepyfile(
358+
"""
359+
import pytest
360+
from opentelemetry import trace
361+
362+
tracer = trace.get_tracer('inside')
363+
364+
@pytest.fixture(scope='session')
365+
def session_scoped() -> int:
366+
return 1
367+
368+
@pytest.fixture(scope='module')
369+
def module_scoped() -> int:
370+
return 2
371+
372+
@pytest.fixture(scope='function')
373+
def function_scoped() -> int:
374+
return 3
375+
376+
def test_one(session_scoped: int, module_scoped: int, function_scoped: int):
377+
assert session_scoped + module_scoped + function_scoped == 6
378+
"""
379+
)
380+
pytester.runpytest().assert_outcomes(passed=1)
381+
382+
spans = span_recorder.spans_by_name()
383+
384+
test_run = spans['test run']
385+
assert test_run.context.trace_id
386+
assert all(
387+
span.context.trace_id == test_run.context.trace_id for span in spans.values()
388+
)
389+
390+
test = spans['test_one']
391+
392+
setup = spans['setup']
393+
assert setup.parent.span_id == test.context.span_id
394+
395+
session_scoped = spans['session_scoped']
396+
module_scoped = spans['module_scoped']
397+
function_scoped = spans['function_scoped']
398+
399+
assert session_scoped.parent.span_id == test_run.context.span_id
400+
assert module_scoped.parent.span_id == test_run.context.span_id
401+
assert function_scoped.parent.span_id == setup.context.span_id
402+
403+
404+
def test_parametrized_fixture_names(
405+
pytester: Pytester, span_recorder: SpanRecorder
406+
) -> None:
407+
pytester.makepyfile(
408+
"""
409+
import pytest
410+
from opentelemetry import trace
411+
412+
class Nope:
413+
def __str__(self):
414+
raise ValueError('nope')
415+
416+
@pytest.fixture(params=[111, 222])
417+
def stringable(request) -> int:
418+
return request.param
419+
420+
@pytest.fixture(params=[Nope(), Nope()])
421+
def unstringable(request) -> Nope:
422+
return request.param
423+
424+
def test_one(stringable: int, unstringable: Nope):
425+
assert isinstance(stringable, int)
426+
assert isinstance(unstringable, Nope)
427+
"""
428+
)
429+
pytester.runpytest().assert_outcomes(passed=4)
430+
431+
spans = span_recorder.spans_by_name()
432+
433+
test_run = spans['test run']
434+
assert test_run.context.trace_id
435+
assert all(
436+
span.context.trace_id == test_run.context.trace_id for span in spans.values()
437+
)
438+
439+
# the stringable arguments are used in the span name
440+
assert 'stringable[111]' in spans
441+
assert 'stringable[222]' in spans
442+
443+
# the indexes of non-stringable arguments are used in the span name
444+
assert 'unstringable[0]' in spans
445+
assert 'unstringable[1]' in spans

0 commit comments

Comments
 (0)