Skip to content

Commit 0cb5a62

Browse files
authored
Refined fixture handling (#29)
* Use nodeid instead of name for the span name. Fixes #27 * Capture test function calls distinct from setup and teardown, and individual fixture teardowns. All fixtures setups and teardowns are included under a test function. For fixtures scoped higher than a function, their setup is under the first function that requested them, and teardown under the last function that did so. Fixes #28
1 parent 3a5f5e2 commit 0cb5a62

File tree

2 files changed

+194
-61
lines changed

2 files changed

+194
-61
lines changed

src/pytest_opentelemetry/instrumentation.py

+84-26
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55
from _pytest.config import Config
6-
from _pytest.fixtures import FixtureDef, SubRequest
6+
from _pytest.fixtures import FixtureDef, FixtureRequest, SubRequest
77
from _pytest.main import Session
88
from _pytest.nodes import Item, Node
99
from _pytest.reports import TestReport
@@ -103,61 +103,119 @@ def _attributes_from_item(self, item: Item) -> Dict[str, Union[str, int]]:
103103
return attributes
104104

105105
@pytest.hookimpl(hookwrapper=True)
106-
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
106+
def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]:
107+
context = trace.set_span_in_context(self.session_span)
107108
with tracer.start_as_current_span(
108-
'setup',
109+
item.nodeid,
109110
attributes=self._attributes_from_item(item),
111+
context=context,
110112
):
111113
yield
112114

113115
@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)
116+
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
117+
with tracer.start_as_current_span(
118+
f'{item.nodeid}::setup',
119+
attributes=self._attributes_from_item(item),
120+
):
121+
yield
120122

123+
def _attributes_from_fixturedef(
124+
self, fixturedef: FixtureDef
125+
) -> Dict[str, Union[str, int]]:
126+
return {
127+
SpanAttributes.CODE_FILEPATH: fixturedef.func.__code__.co_filename,
128+
SpanAttributes.CODE_FUNCTION: fixturedef.argname,
129+
SpanAttributes.CODE_LINENO: fixturedef.func.__code__.co_firstlineno,
130+
"pytest.fixture_scope": fixturedef.scope,
131+
"pytest.span_type": "fixture",
132+
}
133+
134+
def _name_from_fixturedef(self, fixturedef: FixtureDef, request: FixtureRequest):
121135
if fixturedef.params and 'request' in fixturedef.argnames:
122136
try:
123137
parameter = str(request.param)
124138
except Exception:
125139
parameter = str(
126140
request.param_index if isinstance(request, SubRequest) else '?'
127141
)
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-
}
142+
return f"{fixturedef.argname}[{parameter}]"
143+
return fixturedef.argname
139144

140-
with tracer.start_as_current_span(name, context=context, attributes=attributes):
145+
@pytest.hookimpl(hookwrapper=True)
146+
def pytest_fixture_setup(
147+
self, fixturedef: FixtureDef, request: FixtureRequest
148+
) -> Generator[None, None, None]:
149+
with tracer.start_as_current_span(
150+
name=f'{self._name_from_fixturedef(fixturedef, request)} setup',
151+
attributes=self._attributes_from_fixturedef(fixturedef),
152+
):
141153
yield
142154

143155
@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)
156+
def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
146157
with tracer.start_as_current_span(
147-
item.name,
158+
name=f'{item.nodeid}::call',
148159
attributes=self._attributes_from_item(item),
149-
context=context,
150160
):
151161
yield
152162

153163
@pytest.hookimpl(hookwrapper=True)
154164
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
155165
with tracer.start_as_current_span(
156-
'teardown',
166+
name=f'{item.nodeid}::teardown',
157167
attributes=self._attributes_from_item(item),
158168
):
169+
# Since there is no pytest_fixture_teardown hook, we have to be a
170+
# little clever to capture the spans for each fixture's teardown.
171+
# The pytest_fixture_post_finalizer hook is called at the end of a
172+
# fixture's teardown, but we don't know when the fixture actually
173+
# began tearing down.
174+
#
175+
# Instead start a span here for the first fixture to be torn down,
176+
# but give it a temporary name, since we don't know which fixture it
177+
# will be. Then, in pytest_fixture_post_finalizer, when we do know
178+
# which fixture is being torn down, update the name and attributes
179+
# to the actual fixture, end the span, and create the span for the
180+
# next fixture in line to be torn down.
181+
self._fixture_teardown_span = tracer.start_span("fixture teardown")
159182
yield
160183

184+
# The last call to pytest_fixture_post_finalizer will create
185+
# a span that is unneeded, so delete it.
186+
del self._fixture_teardown_span
187+
188+
@pytest.hookimpl(hookwrapper=True)
189+
def pytest_fixture_post_finalizer(
190+
self, fixturedef: FixtureDef, request: SubRequest
191+
) -> Generator[None, None, None]:
192+
"""When the span for a fixture teardown is created by
193+
pytest_runtest_teardown or a previous pytest_fixture_post_finalizer, we
194+
need to update the name and attributes now that we know which fixture it
195+
was for."""
196+
197+
# If the fixture has already been torn down, then it will have no cached
198+
# result, so we can skip this one.
199+
if fixturedef.cached_result is None:
200+
yield
201+
# Passing `-x` option to pytest can cause it to exit early so it may not
202+
# have this span attribute.
203+
elif not hasattr(self, "_fixture_teardown_span"): # pragma: no cover
204+
yield
205+
else:
206+
# If we've gotten here, we have a real fixture about to be torn down.
207+
name = f'{self._name_from_fixturedef(fixturedef, request)} teardown'
208+
self._fixture_teardown_span.update_name(name)
209+
attributes = self._attributes_from_fixturedef(fixturedef)
210+
self._fixture_teardown_span.set_attributes(attributes)
211+
yield
212+
self._fixture_teardown_span.end()
213+
214+
# Create the span for the next fixture to be torn down. When there are
215+
# no more fixtures remaining, this will be an empty, useless span, so it
216+
# needs to be deleted by pytest_runtest_teardown.
217+
self._fixture_teardown_span = tracer.start_span("fixture teardown")
218+
161219
@staticmethod
162220
def pytest_exception_interact(
163221
node: Node,

0 commit comments

Comments
 (0)