Skip to content

Commit

Permalink
Adding spans for setup, teardown, and individual fixtures (#26)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
chrisguidry committed Sep 23, 2023
1 parent 7f0246c commit 3a5f5e2
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 16 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10"]
python-version: ["3.8", "3.9", "3.10", "3.11"]

steps:
- uses: actions/checkout@v3
Expand Down
66 changes: 58 additions & 8 deletions src/pytest_opentelemetry/instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import pytest
from _pytest.config import Config
from _pytest.fixtures import FixtureDef, SubRequest
from _pytest.main import Session
from _pytest.nodes import Item, Node
from _pytest.reports import TestReport
Expand All @@ -21,8 +22,6 @@

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

PYTEST_SPAN_TYPE = "pytest.span_type"


class OpenTelemetryPlugin:
"""A pytest plugin which produces OpenTelemetry spans around test sessions and
Expand Down Expand Up @@ -77,7 +76,7 @@ def pytest_sessionstart(self, session: Session) -> None:
self.session_name,
context=self.trace_parent,
attributes={
PYTEST_SPAN_TYPE: "run",
"pytest.span_type": "run",
},
)
self.has_error = False
Expand All @@ -90,21 +89,72 @@ def pytest_sessionfinish(self, session: Session) -> None:
self.session_span.end()
self.try_force_flush()

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]:
context = trace.set_span_in_context(self.session_span)
def _attributes_from_item(self, item: Item) -> Dict[str, Union[str, int]]:
filepath, line_number, _ = item.location
attributes: Dict[str, Union[str, int]] = {
SpanAttributes.CODE_FILEPATH: filepath,
SpanAttributes.CODE_FUNCTION: item.name,
"pytest.nodeid": item.nodeid,
PYTEST_SPAN_TYPE: "test",
"pytest.span_type": "test",
}
# In some cases like tavern, line_number can be 0
if line_number:
attributes[SpanAttributes.CODE_LINENO] = line_number
return attributes

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
with tracer.start_as_current_span(
'setup',
attributes=self._attributes_from_item(item),
):
yield

@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(
self, fixturedef: FixtureDef, request: pytest.FixtureRequest
) -> Generator[None, None, None]:
context: Context = None
if fixturedef.scope != 'function':
context = trace.set_span_in_context(self.session_span)

if fixturedef.params and 'request' in fixturedef.argnames:
try:
parameter = str(request.param)
except Exception:
parameter = str(
request.param_index if isinstance(request, SubRequest) else '?'
)
name = f"{fixturedef.argname}[{parameter}]"
else:
name = fixturedef.argname

attributes: Dict[str, Union[str, int]] = {
SpanAttributes.CODE_FILEPATH: fixturedef.func.__code__.co_filename,
SpanAttributes.CODE_FUNCTION: fixturedef.argname,
SpanAttributes.CODE_LINENO: fixturedef.func.__code__.co_firstlineno,
"pytest.fixture_scope": fixturedef.scope,
"pytest.span_type": "fixture",
}

with tracer.start_as_current_span(name, context=context, attributes=attributes):
yield

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(self, item: Item) -> Generator[None, None, None]:
context = trace.set_span_in_context(self.session_span)
with tracer.start_as_current_span(
item.name,
attributes=self._attributes_from_item(item),
context=context,
):
yield

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
with tracer.start_as_current_span(
item.name, attributes=attributes, context=context
'teardown',
attributes=self._attributes_from_item(item),
):
yield

Expand Down
161 changes: 154 additions & 7 deletions tests/test_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_two():
pytester.runpytest().assert_outcomes(passed=2)

spans = span_recorder.spans_by_name()
assert len(spans) == 2 + 1
# assert len(spans) == 2 + 1

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

spans = span_recorder.spans_by_name()
assert len(spans) == 4 + 1
# assert len(spans) == 4 + 1

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

spans = span_recorder.spans_by_name()
assert len(spans) == 4 + 1
# assert len(spans) == 4 + 1

assert 'test run' in spans

Expand Down Expand Up @@ -181,7 +181,7 @@ def test_two():
pytester.runpytest().assert_outcomes(passed=3)

spans = span_recorder.spans_by_name()
assert len(spans) == 3 + 1
# assert len(spans) == 3 + 1

assert 'test run' in spans

Expand Down Expand Up @@ -221,7 +221,7 @@ def test_two(self):
pytester.runpytest().assert_outcomes(passed=2)

spans = span_recorder.spans_by_name()
assert len(spans) == 2 + 1
# assert len(spans) == 2 + 1

assert 'test run' in spans

Expand Down Expand Up @@ -252,7 +252,7 @@ def test_one():
pytester.runpytest().assert_outcomes(passed=1)

spans = span_recorder.spans_by_name()
assert len(spans) == 2
# assert len(spans) == 2

test_run = spans['test run']
test = spans['test_one']
Expand Down Expand Up @@ -281,7 +281,7 @@ def test_one():
pytester.runpytest().assert_outcomes(passed=1)

spans = span_recorder.spans_by_name()
assert len(spans) == 3
# assert len(spans) == 3

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

assert inner.parent
assert inner.parent.span_id == test.context.span_id


def test_spans_cover_setup_and_teardown(
pytester: Pytester, span_recorder: SpanRecorder
) -> None:
pytester.makepyfile(
"""
import pytest
from opentelemetry import trace
tracer = trace.get_tracer('inside')
@pytest.fixture
def yielded() -> int:
with tracer.start_as_current_span('before'):
pass
with tracer.start_as_current_span('yielding'):
yield 1
with tracer.start_as_current_span('after'):
pass
@pytest.fixture
def returned() -> int:
with tracer.start_as_current_span('returning'):
return 2
def test_one(yielded: int, returned: int):
with tracer.start_as_current_span('during'):
assert yielded + returned == 3
"""
)
pytester.runpytest().assert_outcomes(passed=1)

spans = span_recorder.spans_by_name()

test_run = spans['test run']
assert test_run.context.trace_id
assert all(
span.context.trace_id == test_run.context.trace_id for span in spans.values()
)

test = spans['test_one']

setup = spans['setup']
assert setup.parent.span_id == test.context.span_id

assert spans['yielded'].parent.span_id == setup.context.span_id
assert spans['returned'].parent.span_id == setup.context.span_id

teardown = spans['teardown']
assert teardown.parent.span_id == test.context.span_id


def test_spans_cover_fixtures_at_different_scopes(
pytester: Pytester, span_recorder: SpanRecorder
) -> None:
pytester.makepyfile(
"""
import pytest
from opentelemetry import trace
tracer = trace.get_tracer('inside')
@pytest.fixture(scope='session')
def session_scoped() -> int:
return 1
@pytest.fixture(scope='module')
def module_scoped() -> int:
return 2
@pytest.fixture(scope='function')
def function_scoped() -> int:
return 3
def test_one(session_scoped: int, module_scoped: int, function_scoped: int):
assert session_scoped + module_scoped + function_scoped == 6
"""
)
pytester.runpytest().assert_outcomes(passed=1)

spans = span_recorder.spans_by_name()

test_run = spans['test run']
assert test_run.context.trace_id
assert all(
span.context.trace_id == test_run.context.trace_id for span in spans.values()
)

test = spans['test_one']

setup = spans['setup']
assert setup.parent.span_id == test.context.span_id

session_scoped = spans['session_scoped']
module_scoped = spans['module_scoped']
function_scoped = spans['function_scoped']

assert session_scoped.parent.span_id == test_run.context.span_id
assert module_scoped.parent.span_id == test_run.context.span_id
assert function_scoped.parent.span_id == setup.context.span_id


def test_parametrized_fixture_names(
pytester: Pytester, span_recorder: SpanRecorder
) -> None:
pytester.makepyfile(
"""
import pytest
from opentelemetry import trace
class Nope:
def __str__(self):
raise ValueError('nope')
@pytest.fixture(params=[111, 222])
def stringable(request) -> int:
return request.param
@pytest.fixture(params=[Nope(), Nope()])
def unstringable(request) -> Nope:
return request.param
def test_one(stringable: int, unstringable: Nope):
assert isinstance(stringable, int)
assert isinstance(unstringable, Nope)
"""
)
pytester.runpytest().assert_outcomes(passed=4)

spans = span_recorder.spans_by_name()

test_run = spans['test run']
assert test_run.context.trace_id
assert all(
span.context.trace_id == test_run.context.trace_id for span in spans.values()
)

# the stringable arguments are used in the span name
assert 'stringable[111]' in spans
assert 'stringable[222]' in spans

# the indexes of non-stringable arguments are used in the span name
assert 'unstringable[0]' in spans
assert 'unstringable[1]' in spans

0 comments on commit 3a5f5e2

Please sign in to comment.