├── .flake8 ├── .github └── workflows │ ├── build-and-test.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── setup.cfg ├── src └── pytest_opentelemetry │ ├── __init__.py │ ├── instrumentation.py │ ├── plugin.py │ ├── py.typed │ └── resource.py └── tests ├── __init__.py ├── conftest.py ├── test_plugin.py ├── test_resource.py ├── test_sessions.py └── test_spans.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.8", "3.9", "3.10", "3.11"] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - run: python -m pip install --upgrade pip build 26 | - run: python -m build 27 | - run: pip install -e '.[dev]' 28 | - run: pytest 29 | - run: pre-commit run --all-files 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.10" 22 | - run: python -m pip install --upgrade pip build 23 | - run: python -m build 24 | - if: startsWith(github.ref, 'refs/tags') 25 | uses: pypa/gh-action-pypi-publish@release/v1 26 | with: 27 | password: ${{ secrets.PYPI_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bookkeeping 2 | .coverage 3 | dist 4 | .mypy_cache 5 | .pytest_cache 6 | *.pyc 7 | __pycache__ 8 | .python-version 9 | *.egg-info 10 | .vscode 11 | .venv 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: "v4.4.0" 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | exclude: "py\\.typed" 8 | - id: check-toml 9 | - id: check-yaml 10 | - repo: https://github.com/PyCQA/isort 11 | rev: "5.12.0" 12 | hooks: 13 | - id: isort 14 | - repo: https://github.com/psf/black 15 | rev: "23.1.0" 16 | hooks: 17 | - id: black-jupyter 18 | - repo: https://github.com/PyCQA/flake8 19 | rev: "6.0.0" 20 | hooks: 21 | - id: flake8 22 | - repo: https://github.com/pre-commit/mirrors-mypy 23 | rev: "v1.1.1" 24 | hooks: 25 | - id: mypy 26 | additional_dependencies: 27 | - pytest 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Chris Guidry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: install 3 | 4 | .bookkeeping/development.in: setup.cfg pyproject.toml 5 | mkdir -p .bookkeeping 6 | echo "-e .[dev]" > .bookkeeping/development.in 7 | 8 | .bookkeeping/installed: .bookkeeping/pip-tools .bookkeeping/development.txt 9 | pip-sync .bookkeeping/development.txt 10 | 11 | ifdef PYENV_VIRTUAL_ENV 12 | pyenv rehash 13 | endif 14 | 15 | touch .bookkeeping/installed 16 | 17 | .bookkeeping/pip-tools: 18 | mkdir -p .bookkeeping 19 | touch .bookkeeping/pip-tools.next 20 | 21 | pip install -U pip pip-tools 22 | 23 | ifdef PYENV_VIRTUAL_ENV 24 | pyenv rehash 25 | endif 26 | 27 | mv .bookkeeping/pip-tools.next .bookkeeping/pip-tools 28 | 29 | %.txt: %.in .bookkeeping/pip-tools 30 | touch $@.next 31 | pip-compile --upgrade --output-file $@.next $< 32 | mv $@.next $@ 33 | 34 | .git/hooks/pre-commit: .bookkeeping/development.txt 35 | pre-commit install 36 | 37 | .PHONY: install 38 | install: .bookkeeping/installed .git/hooks/pre-commit 39 | 40 | .PHONY: clean 41 | clean: 42 | rm -Rf .bookkeeping/ 43 | rm -Rf dist/* 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-opentelemetry 2 | 3 | Instruments your pytest runs, exporting the spans and timing via OpenTelemetry. 4 | 5 | ## Why instrument my test suite? 6 | 7 | As projects grow larger, perhaps with many contributors, test suite runtime can be 8 | a significant limiting factor to how fast you and your team can deliver changes. By 9 | measuring your test suite's runtime in detail, and keeping a history of this runtime 10 | in a visualization tool like [Jaeger](https://jaegertracing.io), you can spot 11 | test bottlenecks that might be slowing your entire suite down. 12 | 13 | Additionally, `pytest` makes an excellent driver for _integration_ tests that operate 14 | on fully deployed systems, like your testing/staging environment. By using 15 | `pytest-opentelemetry` and configuring the appropriate propagators, you can connect 16 | traces from your integration test suite to your running system to analyze failures 17 | more quickly. 18 | 19 | Even if you only enable `pytest-opentelemetry` locally for occasional debugging, it 20 | can help you understand _exactly_ what is slowing your test suite down. Did you 21 | forget to mock that `requests` call? Didn't realize the test suite was creating 22 | 10,000 example accounts? Should that database setup fixture be marked 23 | `scope=module`? These are the kinds of questions `pytest-opentelemetry` can help 24 | you answer. 25 | 26 | `pytest-opentelemetry` works even better when testing applications and libraries that 27 | are themselves instrumented with OpenTelemetry. This will give you deeper visibility 28 | into the layers of your stack, like database queries and network requests. 29 | 30 | ## Installation and usage 31 | 32 | ```bash 33 | pip install pytest-opentelemetry 34 | ``` 35 | 36 | Installing a library that exposes a specific pytest-related entry point is automatically 37 | loaded as a pytest plugin. Simply installing the plugin should be enough to register 38 | it for pytest. 39 | 40 | Using the `--export-traces` flag enables trace exporting (otherwise, the created spans 41 | will only be tracked in memory): 42 | 43 | ```bash 44 | pytest --export-traces 45 | ``` 46 | 47 | By default, this exports traces to `http://localhost:4317`, which will work well if 48 | you're running a local [OpenTelemetry 49 | Collector](https://opentelemetry.io/docs/collector/) exposing the OTLP gRPC interface. 50 | You can use any of the [OpenTelemetry environment 51 | variables](https://opentelemetry-python.readthedocs.io/en/latest/sdk/environment_variables.html) 52 | to adjust the tracing export or behavior: 53 | 54 | ```bash 55 | export OTEL_EXPORTER_OTLP_ENDPOINT=http://another.collector:4317 56 | pytest --export-traces 57 | ``` 58 | 59 | Only the OTLP over gRPC exporter is currently supported. 60 | 61 | `pytest-opentelemetry` will use the name of the project's directory as the OpenTelemetry 62 | `service.name`, but it will also respect the standard `OTEL_SERVICE_NAME` and 63 | `OTEL_RESOURCE_ATTRIBUTES` environment variables. If you would like to permanently 64 | specify those for your project, consider using the very helpful 65 | [`pytest-env`](https://pypi.org/project/pytest-env/) package to set these for all test 66 | runs, for example, in your `pyproject.toml`: 67 | 68 | ```toml 69 | [tool.pytest.ini_options] 70 | env = [ 71 | "OTEL_RESOURCE_ATTRIBUTES=service.name=my-project", 72 | ] 73 | ``` 74 | 75 | If you are using the delightful [`pytest-xdist`](https://pypi.org/project/pytest-xdist/) 76 | package to spread your tests out over multiple processes or hosts, 77 | `pytest-opentelemetry` will automatically unite them all under one trace. If this 78 | `pytest` run is part of a larger trace, you can provide a `--trace-parent` argument to 79 | nest this run under that parent: 80 | 81 | ```bash 82 | pytest ... --trace-parent 00-1234567890abcdef1234567890abcdef-fedcba0987654321-01 83 | ``` 84 | 85 | ## Visualizing test traces 86 | 87 | One quick way to visualize test traces would be to use an [OpenTelemetry 88 | Collector](https://opentelemetry.io/docs/collector/) feeding traces to 89 | [Jaeger](https://jaegertracing.io). This can be configured with a minimal Docker 90 | Compose file like: 91 | 92 | ```yaml 93 | version: "3.8" 94 | services: 95 | jaeger: 96 | image: jaegertracing/all-in-one:1.33 97 | ports: 98 | - 16686:16686 # frontend 99 | - 14250:14250 # model.proto 100 | collector: 101 | image: otel/opentelemetry-collector-contrib:0.49.0 102 | depends_on: 103 | - jaeger 104 | ports: 105 | - 4317:4317 # OTLP (gRPC) 106 | volumes: 107 | - ./otelcol-config.yaml:/etc/otelcol-contrib/config.yaml:ro 108 | ``` 109 | 110 | With this `otelcol-config.yaml`: 111 | 112 | ```yaml 113 | receivers: 114 | otlp: 115 | protocols: 116 | grpc: 117 | 118 | processors: 119 | batch: 120 | 121 | exporters: 122 | jaeger: 123 | endpoint: jaeger:14250 124 | tls: 125 | insecure: true 126 | 127 | service: 128 | pipelines: 129 | traces: 130 | receivers: [otlp] 131 | processors: [batch] 132 | exporters: [jaeger] 133 | ``` 134 | 135 | ## Developing 136 | 137 | Two references I keep returning to is the pytest guide on writing plugins, and the 138 | pytest API reference: 139 | 140 | * https://docs.pytest.org/en/6.2.x/writing_plugins.html 141 | * https://docs.pytest.org/en/6.2.x/reference.html#hooks 142 | 143 | These are extremely helpful in understanding the lifecycle of a pytest run. 144 | 145 | To get setup for development, you will likely want to use a "virtual environment", using 146 | great tools like `virtualenv` or `pyenv`. 147 | 148 | Once you have a virtual environment, install this package for editing, along with its 149 | development dependencies, with this command: 150 | 151 | ```bash 152 | pip install -e '.[dev]' 153 | ``` 154 | 155 | When sending pull requests, don't forget to bump the version in 156 | [setup.cfg](./setup.cfg). 157 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=62", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | 7 | [tool.black] 8 | skip-string-normalization = true 9 | target-version = ['py39'] 10 | 11 | [tool.coverage.report] 12 | omit = [ 13 | "src/pytest_opentelemetry/__init__.py", 14 | "src/pytest_opentelemetry/plugin.py" 15 | ] 16 | 17 | [tool.isort] 18 | profile = "black" 19 | 20 | [tool.mypy] 21 | namespace_packages = true 22 | 23 | [[tool.mypy.overrides]] 24 | module = ['xdist.workermanage'] 25 | ignore_missing_imports = true 26 | 27 | [tool.pylint.messages_control] 28 | disable = [ 29 | 'attribute-defined-outside-init', 30 | 'fixme', 31 | 32 | 'missing-function-docstring', 33 | 'missing-module-docstring', 34 | 35 | # fundamental to how pytest fixtures work 36 | 'redefined-outer-name', 37 | 'unused-argument' 38 | ] 39 | 40 | [tool.pytest.ini_options] 41 | minversion = "6.0" 42 | addopts = """ 43 | --cov=src 44 | --cov=tests 45 | --cov-branch 46 | --cov-report=term-missing 47 | --cov-fail-under=100 48 | --no-cov-on-fail 49 | """ 50 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pytest-opentelemetry 3 | version = attr: pytest_opentelemetry.__version__ 4 | author = Chris Guidry 5 | author_email = chris@theguidrys.us 6 | description = A pytest plugin for instrumenting test runs via OpenTelemetry 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/chrisguidry/pytest-opentelemetry 10 | project_urls = 11 | Bug Tracker = https://github.com/chrisguidry/pytest-opentelemetry/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | Framework :: Pytest 17 | 18 | [options] 19 | package_dir = 20 | = src 21 | packages = find: 22 | python_requires = >=3.8 23 | install_requires = 24 | opentelemetry-api 25 | opentelemetry-container-distro 26 | opentelemetry-sdk 27 | opentelemetry-semantic-conventions 28 | pytest 29 | 30 | [options.extras_require] 31 | dev = 32 | black 33 | build 34 | flake8 35 | flake8-black 36 | isort 37 | mypy 38 | pre-commit 39 | pytest 40 | pytest-cov 41 | pytest-xdist 42 | twine 43 | 44 | [options.packages.find] 45 | where = src 46 | 47 | [options.entry_points] 48 | pytest11 = 49 | pytest_opentelemetry = pytest_opentelemetry.plugin 50 | -------------------------------------------------------------------------------- /src/pytest_opentelemetry/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | __version__ = version("pytest_opentelemetry") 4 | -------------------------------------------------------------------------------- /src/pytest_opentelemetry/instrumentation.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, Dict, Iterator, Optional, Union 3 | 4 | import pytest 5 | from _pytest.config import Config 6 | from _pytest.fixtures import FixtureDef, FixtureRequest, SubRequest 7 | from _pytest.main import Session 8 | from _pytest.nodes import Item, Node 9 | from _pytest.reports import TestReport 10 | from _pytest.runner import CallInfo 11 | from opentelemetry import propagate, trace 12 | from opentelemetry.context.context import Context 13 | from opentelemetry.sdk.resources import OTELResourceDetector 14 | from opentelemetry.semconv.trace import SpanAttributes 15 | from opentelemetry.trace import Status, StatusCode 16 | from opentelemetry_container_distro import ( 17 | OpenTelemetryContainerConfigurator, 18 | OpenTelemetryContainerDistro, 19 | ) 20 | 21 | from .resource import CodebaseResourceDetector 22 | 23 | tracer = trace.get_tracer('pytest-opentelemetry') 24 | 25 | 26 | class PerTestOpenTelemetryPlugin: 27 | """base logic for all otel pytest integration""" 28 | 29 | @property 30 | def item_parent(self) -> Union[str, None]: 31 | return self.trace_parent 32 | 33 | @classmethod 34 | def get_trace_parent(cls, config: Config) -> Optional[Context]: 35 | if trace_parent := config.getvalue('--trace-parent'): 36 | return propagate.extract({'traceparent': trace_parent}) 37 | 38 | if trace_parent := os.environ.get('TRACEPARENT'): 39 | return propagate.extract({'traceparent': trace_parent}) 40 | 41 | return None 42 | 43 | @classmethod 44 | def try_force_flush(cls) -> bool: 45 | provider = trace.get_tracer_provider() 46 | 47 | # Not all providers (e.g. ProxyTraceProvider) implement force flush 48 | if hasattr(provider, 'force_flush'): 49 | provider.force_flush() 50 | return True 51 | else: 52 | return False 53 | 54 | def pytest_configure(self, config: Config) -> None: 55 | self.trace_parent = self.get_trace_parent(config) 56 | 57 | # This can't be tested both ways in one process 58 | if config.getoption('--export-traces'): # pragma: no cover 59 | OpenTelemetryContainerDistro().configure() 60 | 61 | configurator = OpenTelemetryContainerConfigurator() 62 | configurator.resource_detectors.append(CodebaseResourceDetector(config)) 63 | configurator.resource_detectors.append(OTELResourceDetector()) 64 | configurator.configure() 65 | 66 | def pytest_sessionfinish(self, session: Session) -> None: 67 | self.try_force_flush() 68 | 69 | def _attributes_from_item(self, item: Item) -> Dict[str, Union[str, int]]: 70 | filepath, line_number, _ = item.location 71 | attributes: Dict[str, Union[str, int]] = { 72 | SpanAttributes.CODE_FILEPATH: filepath, 73 | SpanAttributes.CODE_FUNCTION: item.name, 74 | "pytest.nodeid": item.nodeid, 75 | "pytest.span_type": "test", 76 | } 77 | # In some cases like tavern, line_number can be 0 78 | if line_number: 79 | attributes[SpanAttributes.CODE_LINENO] = line_number 80 | return attributes 81 | 82 | @pytest.hookimpl(hookwrapper=True) 83 | def pytest_runtest_protocol(self, item: Item) -> Iterator[None]: 84 | with tracer.start_as_current_span( 85 | item.nodeid, 86 | attributes=self._attributes_from_item(item), 87 | context=self.item_parent, 88 | ): 89 | yield 90 | 91 | @pytest.hookimpl(hookwrapper=True) 92 | def pytest_runtest_setup(self, item: Item) -> Iterator[None]: 93 | with tracer.start_as_current_span( 94 | f'{item.nodeid}::setup', 95 | attributes=self._attributes_from_item(item), 96 | ): 97 | yield 98 | 99 | def _attributes_from_fixturedef( 100 | self, fixturedef: FixtureDef 101 | ) -> Dict[str, Union[str, int]]: 102 | return { 103 | SpanAttributes.CODE_FILEPATH: fixturedef.func.__code__.co_filename, 104 | SpanAttributes.CODE_FUNCTION: fixturedef.argname, 105 | SpanAttributes.CODE_LINENO: fixturedef.func.__code__.co_firstlineno, 106 | "pytest.fixture_scope": fixturedef.scope, 107 | "pytest.span_type": "fixture", 108 | } 109 | 110 | def _name_from_fixturedef(self, fixturedef: FixtureDef, request: FixtureRequest): 111 | if fixturedef.params and 'request' in fixturedef.argnames: 112 | try: 113 | parameter = str(request.param) 114 | except Exception: 115 | parameter = str( 116 | request.param_index if isinstance(request, SubRequest) else '?' 117 | ) 118 | return f"{fixturedef.argname}[{parameter}]" 119 | return fixturedef.argname 120 | 121 | @pytest.hookimpl(hookwrapper=True) 122 | def pytest_fixture_setup( 123 | self, fixturedef: FixtureDef, request: FixtureRequest 124 | ) -> Iterator[None]: 125 | with tracer.start_as_current_span( 126 | name=f'{self._name_from_fixturedef(fixturedef, request)} setup', 127 | attributes=self._attributes_from_fixturedef(fixturedef), 128 | ): 129 | yield 130 | 131 | @pytest.hookimpl(hookwrapper=True) 132 | def pytest_runtest_call(self, item: Item) -> Iterator[None]: 133 | with tracer.start_as_current_span( 134 | name=f'{item.nodeid}::call', 135 | attributes=self._attributes_from_item(item), 136 | ): 137 | yield 138 | 139 | @pytest.hookimpl(hookwrapper=True) 140 | def pytest_runtest_teardown(self, item: Item) -> Iterator[None]: 141 | with tracer.start_as_current_span( 142 | name=f'{item.nodeid}::teardown', 143 | attributes=self._attributes_from_item(item), 144 | ): 145 | # Since there is no pytest_fixture_teardown hook, we have to be a 146 | # little clever to capture the spans for each fixture's teardown. 147 | # The pytest_fixture_post_finalizer hook is called at the end of a 148 | # fixture's teardown, but we don't know when the fixture actually 149 | # began tearing down. 150 | # 151 | # Instead start a span here for the first fixture to be torn down, 152 | # but give it a temporary name, since we don't know which fixture it 153 | # will be. Then, in pytest_fixture_post_finalizer, when we do know 154 | # which fixture is being torn down, update the name and attributes 155 | # to the actual fixture, end the span, and create the span for the 156 | # next fixture in line to be torn down. 157 | self._fixture_teardown_span = tracer.start_span("fixture teardown") 158 | yield 159 | 160 | # The last call to pytest_fixture_post_finalizer will create 161 | # a span that is unneeded, so delete it. 162 | del self._fixture_teardown_span 163 | 164 | @pytest.hookimpl(hookwrapper=True) 165 | def pytest_fixture_post_finalizer( 166 | self, fixturedef: FixtureDef, request: SubRequest 167 | ) -> Iterator[None]: 168 | """When the span for a fixture teardown is created by 169 | pytest_runtest_teardown or a previous pytest_fixture_post_finalizer, we 170 | need to update the name and attributes now that we know which fixture it 171 | was for.""" 172 | 173 | # If the fixture has already been torn down, then it will have no cached 174 | # result, so we can skip this one. 175 | if fixturedef.cached_result is None: 176 | yield 177 | # Passing `-x` option to pytest can cause it to exit early so it may not 178 | # have this span attribute. 179 | elif not hasattr(self, "_fixture_teardown_span"): # pragma: no cover 180 | yield 181 | else: 182 | # If we've gotten here, we have a real fixture about to be torn down. 183 | name = f'{self._name_from_fixturedef(fixturedef, request)} teardown' 184 | self._fixture_teardown_span.update_name(name) 185 | attributes = self._attributes_from_fixturedef(fixturedef) 186 | self._fixture_teardown_span.set_attributes(attributes) 187 | yield 188 | self._fixture_teardown_span.end() 189 | 190 | # Create the span for the next fixture to be torn down. When there are 191 | # no more fixtures remaining, this will be an empty, useless span, so it 192 | # needs to be deleted by pytest_runtest_teardown. 193 | self._fixture_teardown_span = tracer.start_span("fixture teardown") 194 | 195 | @staticmethod 196 | def pytest_exception_interact( 197 | node: Node, 198 | call: CallInfo[Any], 199 | report: TestReport, 200 | ) -> None: 201 | excinfo = call.excinfo 202 | assert excinfo 203 | assert isinstance(excinfo.value, BaseException) 204 | 205 | test_span = trace.get_current_span() 206 | 207 | test_span.record_exception( 208 | # Interface says Exception, but BaseException seems to work fine 209 | # This is needed because pytest's Failed exception inherits from 210 | # BaseException, not Exception 211 | exception=excinfo.value, # type: ignore[arg-type] 212 | attributes={ 213 | SpanAttributes.EXCEPTION_STACKTRACE: str(report.longrepr), 214 | }, 215 | ) 216 | test_span.set_status( 217 | Status( 218 | status_code=StatusCode.ERROR, 219 | description=f"{excinfo.type}: {excinfo.value}", 220 | ) 221 | ) 222 | 223 | def pytest_runtest_logreport(self, report: TestReport) -> None: 224 | if report.when != 'call': 225 | return 226 | 227 | has_error = report.outcome == 'failed' 228 | status_code = StatusCode.ERROR if has_error else StatusCode.OK 229 | trace.get_current_span().set_status(status_code) 230 | 231 | 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 | 275 | try: 276 | from xdist.workermanage import WorkerController # pylint: disable=unused-import 277 | except ImportError: # pragma: no cover 278 | WorkerController = None 279 | 280 | 281 | class XdistOpenTelemetryPlugin(OpenTelemetryPlugin): 282 | """An xdist-aware version of the OpenTelemetryPlugin""" 283 | 284 | @classmethod 285 | def get_trace_parent(cls, config: Config) -> Optional[Context]: 286 | if workerinput := getattr(config, 'workerinput', None): 287 | return propagate.extract(workerinput) 288 | 289 | return super().get_trace_parent(config) 290 | 291 | def pytest_configure(self, config: Config) -> None: 292 | super().pytest_configure(config) 293 | worker_id = getattr(config, 'workerinput', {}).get('workerid') 294 | self.session_name = ( 295 | f'test worker {worker_id}' if worker_id else self.session_name 296 | ) 297 | 298 | def pytest_configure_node(self, node: WorkerController) -> None: # pragma: no cover 299 | with trace.use_span(self.session_span, end_on_exit=False): 300 | propagate.inject(node.workerinput) 301 | 302 | def pytest_xdist_node_collection_finished(node, ids): # pragma: no cover 303 | super().try_force_flush() 304 | -------------------------------------------------------------------------------- /src/pytest_opentelemetry/plugin.py: -------------------------------------------------------------------------------- 1 | from _pytest.config import Config 2 | from _pytest.config.argparsing import Parser 3 | 4 | 5 | def pytest_addoption(parser: Parser) -> None: 6 | group = parser.getgroup('pytest-opentelemetry', 'OpenTelemetry for test runs') 7 | group.addoption( 8 | "--export-traces", 9 | action="store_true", 10 | default=False, 11 | help=( 12 | 'Enables exporting of OpenTelemetry traces via OTLP, by default to ' 13 | 'http://localhost:4317. Set the OTEL_EXPORTER_OTLP_ENDPOINT environment ' 14 | 'variable to specify an alternative endpoint.' 15 | ), 16 | ) 17 | group.addoption( 18 | "--trace-parent", 19 | action="store", 20 | default=None, 21 | help=( 22 | 'Specify a trace parent for this pytest run, in the form of a W3C ' 23 | 'traceparent header, like ' 24 | '00-1234567890abcdef1234567890abcdef-fedcba0987654321-01. If a trace ' 25 | 'parent is provided, this test run will appear as a span within that ' 26 | 'trace. If it is omitted, this test run will start a new trace.' 27 | ), 28 | ) 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 | ) 35 | 36 | 37 | def pytest_configure(config: Config) -> None: 38 | # pylint: disable=import-outside-toplevel 39 | from pytest_opentelemetry.instrumentation import ( 40 | OpenTelemetryPlugin, 41 | PerTestOpenTelemetryPlugin, 42 | XdistOpenTelemetryPlugin, 43 | ) 44 | 45 | if config.getvalue('--trace-per-test'): 46 | config.pluginmanager.register(PerTestOpenTelemetryPlugin()) 47 | elif config.pluginmanager.has_plugin("xdist"): 48 | config.pluginmanager.register(XdistOpenTelemetryPlugin()) 49 | else: 50 | config.pluginmanager.register(OpenTelemetryPlugin()) 51 | -------------------------------------------------------------------------------- /src/pytest_opentelemetry/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/pytest_opentelemetry/resource.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from typing import Dict, Union 3 | 4 | from opentelemetry.sdk.resources import Resource, ResourceDetector 5 | from opentelemetry.semconv.resource import ResourceAttributes 6 | from pytest import Config 7 | 8 | Attributes = Dict[str, Union[str, bool, int, float]] 9 | 10 | 11 | class CodebaseResourceDetector(ResourceDetector): 12 | """Detects OpenTelemetry Resource attributes for an operating system process, 13 | providing the `process.*` attributes""" 14 | 15 | def __init__(self, config: Config): 16 | self.config = config 17 | ResourceDetector.__init__(self) 18 | 19 | def get_codebase_name(self) -> str: 20 | """Get the name of the codebase. 21 | 22 | In order of preference: 23 | junit_suite_name 24 | --junitprefix 25 | rootpath: Guaranteed to exist, the reference used to construct nodeid 26 | """ 27 | return str( 28 | self.config.inicfg.get('junit_suite_name') 29 | or self.config.getoption("--junitprefix", None) 30 | or self.config.rootpath.name 31 | ) 32 | 33 | @staticmethod 34 | def get_codebase_version() -> str: 35 | try: 36 | response = subprocess.check_output( 37 | ['git', 'rev-parse', '--is-inside-work-tree'] 38 | ) 39 | if response.strip() != b'true': 40 | return '[unknown: not a git repository]' 41 | except Exception: # pylint: disable=broad-except 42 | return '[unknown: not a git repository]' 43 | 44 | try: 45 | version = subprocess.check_output(['git', 'rev-parse', 'HEAD']) 46 | except Exception as exception: # pylint: disable=broad-except 47 | return f'[unknown: {str(exception)}]' 48 | 49 | return version.decode().strip() 50 | 51 | def detect(self) -> Resource: 52 | return Resource( 53 | { 54 | ResourceAttributes.SERVICE_NAME: self.get_codebase_name(), 55 | ResourceAttributes.SERVICE_VERSION: self.get_codebase_version(), 56 | } 57 | ) 58 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Sequence, cast 2 | 3 | from opentelemetry.sdk.trace import ReadableSpan 4 | from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter 5 | 6 | 7 | class SpanRecorder(InMemorySpanExporter): 8 | """An OpenTelemetry span exporter that remembers all of the Spans it has seen, 9 | and provides utility methods to inspect them during tests.""" 10 | 11 | def finished_spans(self) -> Sequence[ReadableSpan]: 12 | """Returns read-only versions of all of the remembered Spans""" 13 | return cast(Sequence[ReadableSpan], super().get_finished_spans()) 14 | 15 | def spans_by_name(self) -> Dict[str, ReadableSpan]: 16 | """Returns a dictionary of remembered spans keyed by their names""" 17 | return {s.name: s for s in self.finished_spans()} 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from opentelemetry import trace 3 | from opentelemetry.sdk import trace as trace_sdk 4 | from opentelemetry.sdk.trace.export import SimpleSpanProcessor 5 | 6 | from . import SpanRecorder 7 | 8 | pytest_plugins = ["pytester"] 9 | 10 | 11 | @pytest.fixture(scope='session') 12 | def tracer_provider() -> trace_sdk.TracerProvider: 13 | provider = trace.get_tracer_provider() 14 | assert isinstance(provider, trace_sdk.TracerProvider) 15 | return provider 16 | 17 | 18 | @pytest.fixture(scope='session') 19 | def span_processor(tracer_provider: trace_sdk.TracerProvider) -> SimpleSpanProcessor: 20 | span_processor = SimpleSpanProcessor(SpanRecorder()) 21 | tracer_provider.add_span_processor(span_processor) 22 | return span_processor 23 | 24 | 25 | @pytest.fixture(scope='function') 26 | def span_recorder(span_processor: SimpleSpanProcessor) -> SpanRecorder: 27 | span_processor.span_exporter = SpanRecorder() 28 | return span_processor.span_exporter 29 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | from _pytest.config import Config 2 | from packaging.version import Version 3 | 4 | import pytest_opentelemetry.plugin 5 | from pytest_opentelemetry import __version__ 6 | 7 | 8 | def test_version_is_sane() -> None: 9 | assert __version__ 10 | assert Version(__version__) 11 | assert Version(__version__) > Version('0.4.0') # before we introduced __version__ 12 | 13 | 14 | def test_plugin_is_loaded(pytestconfig: Config) -> None: 15 | plugin = pytestconfig.pluginmanager.get_plugin('pytest_opentelemetry') 16 | assert plugin is pytest_opentelemetry.plugin 17 | assert plugin 18 | -------------------------------------------------------------------------------- /tests/test_resource.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | import tempfile 5 | from typing import Generator 6 | from unittest.mock import MagicMock, Mock, patch 7 | 8 | import pytest 9 | from opentelemetry.sdk.resources import Resource 10 | 11 | from pytest_opentelemetry.resource import CodebaseResourceDetector 12 | 13 | 14 | @pytest.fixture 15 | def bare_codebase() -> Generator[str, None, None]: 16 | previous = os.getcwd() 17 | with tempfile.TemporaryDirectory() as directory: 18 | project_path = os.path.join(directory, 'my-project') 19 | os.makedirs(project_path) 20 | os.chdir(project_path) 21 | try: 22 | yield project_path 23 | finally: 24 | os.chdir(previous) 25 | 26 | 27 | @pytest.fixture 28 | def resource() -> Resource: 29 | return CodebaseResourceDetector(Mock()).detect() 30 | 31 | 32 | def test_get_codebase_name() -> None: 33 | config = MagicMock() 34 | config.inicfg = {'junit_suite_name': 'my-project'} 35 | config.rootpath.name = None 36 | config.getoption.return_value = None 37 | assert CodebaseResourceDetector(config).get_codebase_name() == 'my-project' 38 | 39 | config = MagicMock() 40 | config.inicfg = {} 41 | config.rootpath.name = None 42 | config.getoption.return_value = 'my-project' 43 | assert CodebaseResourceDetector(config).get_codebase_name() == 'my-project' 44 | 45 | config = MagicMock() 46 | config.inicfg = {} 47 | config.rootpath.name = 'my-project' 48 | config.getoption.return_value = None 49 | assert CodebaseResourceDetector(config).get_codebase_name() == 'my-project' 50 | 51 | 52 | def test_service_version_unknown(bare_codebase: str, resource: Resource) -> None: 53 | assert resource.attributes['service.version'] == '[unknown: not a git repository]' 54 | 55 | 56 | def test_service_version_git_problems() -> None: 57 | with patch( 58 | 'pytest_opentelemetry.resource.subprocess.check_output', 59 | side_effect=[ 60 | b'true', 61 | subprocess.CalledProcessError(128, ['git', 'rev-parse', 'HEAD']), 62 | ], 63 | ): 64 | resource = CodebaseResourceDetector(Mock()).detect() 65 | assert resource.attributes['service.version'] == ( 66 | "[unknown: Command '['git', 'rev-parse', 'HEAD']' " 67 | "returned non-zero exit status 128.]" 68 | ) 69 | with patch( 70 | 'pytest_opentelemetry.resource.subprocess.check_output', side_effect=[b'false'] 71 | ): 72 | resource = CodebaseResourceDetector(Mock()).detect() 73 | assert resource.attributes['service.version'] == ( 74 | "[unknown: not a git repository]" 75 | ) 76 | 77 | 78 | @pytest.fixture 79 | def git_repo(bare_codebase: str) -> str: 80 | if os.system('which git') > 0: # pragma: no cover 81 | pytest.skip('No git available on path') 82 | 83 | with open('README.md', 'w', encoding='utf-8') as readme: 84 | readme.write('# hi!\n') 85 | 86 | os.system('git init') 87 | os.system('git config user.email "testing@example.com"') 88 | os.system('git config user.name "Testy McTesterson"') 89 | os.system('git add README.md') 90 | os.system('git commit --message="Saying hi!"') 91 | 92 | return bare_codebase 93 | 94 | 95 | def test_service_version_from_git_revision(git_repo: str, resource: Resource) -> None: 96 | version = resource.attributes['service.version'] 97 | assert isinstance(version, str) 98 | assert len(version) == 40 99 | assert re.match(r'[\da-f]{40}', version) 100 | -------------------------------------------------------------------------------- /tests/test_sessions.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import contextmanager 3 | from typing import Dict, Generator, List, Optional 4 | from unittest.mock import Mock, patch 5 | 6 | import pytest 7 | from _pytest.pytester import Pytester 8 | from opentelemetry import trace 9 | 10 | from pytest_opentelemetry.instrumentation import ( 11 | OpenTelemetryPlugin, 12 | PerTestOpenTelemetryPlugin, 13 | XdistOpenTelemetryPlugin, 14 | ) 15 | 16 | from . import SpanRecorder 17 | 18 | 19 | @contextmanager 20 | def environment(**overrides: Optional[str]) -> Generator[None, None, None]: 21 | original: Dict[str, Optional[str]] = {} 22 | for key, value in overrides.items(): 23 | original[key] = os.environ.pop(key, None) 24 | if value is not None: 25 | os.environ[key] = value 26 | 27 | try: 28 | yield 29 | finally: 30 | for key, value in original.items(): 31 | if value is None: 32 | os.environ.pop(key, None) 33 | else: 34 | os.environ[key] = value 35 | 36 | 37 | def test_environment_manipulation(): 38 | assert 'VALUE' not in os.environ 39 | with environment(VALUE='outer'): 40 | assert os.environ['VALUE'] == 'outer' 41 | with environment(VALUE='inner'): 42 | assert os.environ['VALUE'] == 'inner' 43 | with environment(VALUE=None): 44 | assert 'VALUE' not in os.environ 45 | with environment(VALUE='once more'): 46 | assert os.environ['VALUE'] == 'once more' 47 | assert 'VALUE' not in os.environ 48 | assert os.environ['VALUE'] == 'inner' 49 | assert os.environ['VALUE'] == 'outer' 50 | assert 'VALUE' not in os.environ 51 | 52 | 53 | def test_getting_no_trace_id(pytester: Pytester) -> None: 54 | config = pytester.parseconfig() 55 | context = OpenTelemetryPlugin.get_trace_parent(config) 56 | assert context is None 57 | 58 | 59 | def test_getting_trace_id_from_command_line(pytester: Pytester) -> None: 60 | config = pytester.parseconfig( 61 | '--trace-parent', 62 | '00-1234567890abcdef1234567890abcdef-fedcba0987654321-01', 63 | ) 64 | context = OpenTelemetryPlugin.get_trace_parent(config) 65 | assert context 66 | 67 | parent_span = next(iter(context.values())) 68 | assert isinstance(parent_span, trace.Span) 69 | 70 | parent = parent_span.get_span_context() 71 | assert parent.trace_id == 0x1234567890ABCDEF1234567890ABCDEF 72 | assert parent.span_id == 0xFEDCBA0987654321 73 | 74 | 75 | def test_getting_trace_id_from_environment_variable(pytester: Pytester) -> None: 76 | config = pytester.parseconfig() 77 | 78 | with environment( 79 | TRACEPARENT='00-1234567890abcdef1234567890abcdef-fedcba0987654321-01' 80 | ): 81 | context = OpenTelemetryPlugin.get_trace_parent(config) 82 | 83 | assert context 84 | 85 | parent_span = next(iter(context.values())) 86 | assert isinstance(parent_span, trace.Span) 87 | 88 | parent = parent_span.get_span_context() 89 | assert parent.trace_id == 0x1234567890ABCDEF1234567890ABCDEF 90 | assert parent.span_id == 0xFEDCBA0987654321 91 | 92 | 93 | def test_getting_trace_id_from_worker_input(pytester: Pytester) -> None: 94 | config = pytester.parseconfig() 95 | setattr( 96 | config, 97 | 'workerinput', 98 | {'traceparent': '00-1234567890abcdef1234567890abcdef-fedcba0987654321-01'}, 99 | ) 100 | context = XdistOpenTelemetryPlugin.get_trace_parent(config) 101 | assert context 102 | 103 | parent_span = next(iter(context.values())) 104 | assert isinstance(parent_span, trace.Span) 105 | 106 | parent = parent_span.get_span_context() 107 | assert parent.trace_id == 0x1234567890ABCDEF1234567890ABCDEF 108 | assert parent.span_id == 0xFEDCBA0987654321 109 | 110 | 111 | def test_passing_trace_id(pytester: Pytester, span_recorder: SpanRecorder) -> None: 112 | pytester.makepyfile( 113 | """ 114 | from opentelemetry import trace 115 | 116 | def test_one(worker_id): 117 | # confirm that this is not an xdist worker 118 | assert not worker_id.startswith('gw') 119 | 120 | span = trace.get_current_span() 121 | assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef 122 | assert span.context.span_id != 0xfedcba0987654321 123 | 124 | def test_two(worker_id): 125 | # confirm that this is not an xdist worker 126 | assert not worker_id.startswith('gw') 127 | 128 | span = trace.get_current_span() 129 | assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef 130 | assert span.context.span_id != 0xfedcba0987654321 131 | """ 132 | ) 133 | result = pytester.runpytest_subprocess( 134 | '--trace-parent', 135 | '00-1234567890abcdef1234567890abcdef-fedcba0987654321-01', 136 | ) 137 | result.assert_outcomes(passed=2) 138 | 139 | 140 | def test_multiple_workers(pytester: Pytester, span_recorder: SpanRecorder) -> None: 141 | pytester.makepyfile( 142 | """ 143 | from opentelemetry import trace 144 | 145 | def test_one(worker_id): 146 | # confirm that this is an xdist worker 147 | assert worker_id in {'gw0', 'gw1'} 148 | 149 | span = trace.get_current_span() 150 | assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef 151 | 152 | def test_two(worker_id): 153 | # confirm that this is an xdist worker 154 | assert worker_id in {'gw0', 'gw1'} 155 | 156 | span = trace.get_current_span() 157 | assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef 158 | """ 159 | ) 160 | result = pytester.runpytest_subprocess( 161 | '-n', 162 | '2', 163 | '--trace-parent', 164 | '00-1234567890abcdef1234567890abcdef-fedcba0987654321-01', 165 | ) 166 | result.assert_outcomes(passed=2) 167 | 168 | 169 | def test_works_without_xdist(pytester: Pytester, span_recorder: SpanRecorder) -> None: 170 | pytester.makepyfile( 171 | """ 172 | from opentelemetry import trace 173 | 174 | def test_one(): 175 | span = trace.get_current_span() 176 | assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef 177 | 178 | def test_two(): 179 | span = trace.get_current_span() 180 | assert span.context.trace_id == 0x1234567890abcdef1234567890abcdef 181 | """ 182 | ) 183 | result = pytester.runpytest_subprocess( 184 | '-p', 185 | 'no:xdist', 186 | '--trace-parent', 187 | '00-1234567890abcdef1234567890abcdef-fedcba0987654321-01', 188 | ) 189 | result.assert_outcomes(passed=2) 190 | 191 | 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 | 225 | @patch.object(trace, 'get_tracer_provider') 226 | def test_force_flush_with_supported_provider(mock_get_tracer_provider): 227 | provider = Mock() 228 | provider.force_flush = Mock(return_value=None) 229 | mock_get_tracer_provider.return_value = provider 230 | 231 | for plugin in ( 232 | OpenTelemetryPlugin, 233 | XdistOpenTelemetryPlugin, 234 | PerTestOpenTelemetryPlugin, 235 | ): 236 | assert plugin.try_force_flush() is True 237 | 238 | 239 | @patch.object(trace, 'get_tracer_provider') 240 | def test_force_flush_with_unsupported_provider(mock_get_tracer_provider): 241 | provider = Mock(spec=trace.ProxyTracerProvider) 242 | mock_get_tracer_provider.return_value = provider 243 | 244 | for plugin in ( 245 | OpenTelemetryPlugin, 246 | XdistOpenTelemetryPlugin, 247 | PerTestOpenTelemetryPlugin, 248 | ): 249 | assert plugin.try_force_flush() is False 250 | -------------------------------------------------------------------------------- /tests/test_spans.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from typing import List 3 | 4 | import pytest 5 | from _pytest.pytester import Pytester 6 | from opentelemetry.trace import SpanKind 7 | 8 | from . import SpanRecorder 9 | 10 | 11 | def test_simple_pytest_functions( 12 | pytester: Pytester, span_recorder: SpanRecorder 13 | ) -> None: 14 | pytester.makepyfile( 15 | """ 16 | def test_one(): 17 | assert 1 + 2 == 3 18 | 19 | def test_two(): 20 | assert 2 + 2 == 4 21 | """ 22 | ) 23 | pytester.runpytest().assert_outcomes(passed=2) 24 | 25 | spans = span_recorder.spans_by_name() 26 | # assert len(spans) == 2 + 1 27 | 28 | span = spans['test run'] 29 | assert span.status.is_ok 30 | assert span.attributes 31 | assert span.attributes["pytest.span_type"] == "run" 32 | 33 | span = spans['test_simple_pytest_functions.py::test_one'] 34 | assert span.kind == SpanKind.INTERNAL 35 | assert span.status.is_ok 36 | assert span.attributes 37 | assert span.attributes['code.function'] == 'test_one' 38 | assert span.attributes['code.filepath'] == 'test_simple_pytest_functions.py' 39 | assert 'code.lineno' not in span.attributes 40 | assert span.attributes["pytest.span_type"] == "test" 41 | assert ( 42 | span.attributes["pytest.nodeid"] == "test_simple_pytest_functions.py::test_one" 43 | ) 44 | 45 | span = spans['test_simple_pytest_functions.py::test_two'] 46 | assert span.kind == SpanKind.INTERNAL 47 | assert span.status.is_ok 48 | assert span.attributes 49 | assert span.attributes['code.function'] == 'test_two' 50 | assert span.attributes['code.filepath'] == 'test_simple_pytest_functions.py' 51 | assert span.attributes['code.lineno'] == 3 52 | assert span.attributes["pytest.span_type"] == "test" 53 | assert ( 54 | span.attributes["pytest.nodeid"] == "test_simple_pytest_functions.py::test_two" 55 | ) 56 | 57 | 58 | def test_failures_and_errors(pytester: Pytester, span_recorder: SpanRecorder) -> None: 59 | pytester.makepyfile( 60 | """ 61 | import pytest 62 | 63 | def test_one(): 64 | assert 1 + 2 == 3 65 | 66 | def test_two(): 67 | assert 2 + 2 == 5 68 | 69 | def test_three(): 70 | raise ValueError('woops') 71 | 72 | def test_four(): 73 | # Test did not raise case 74 | with pytest.raises(ValueError): 75 | pass 76 | """ 77 | ) 78 | result = pytester.runpytest() 79 | result.assert_outcomes(passed=1, failed=3) 80 | 81 | spans = span_recorder.spans_by_name() 82 | # assert len(spans) == 4 + 1 83 | 84 | span = spans['test run'] 85 | assert not span.status.is_ok 86 | 87 | span = spans['test_failures_and_errors.py::test_one'] 88 | assert span.status.is_ok 89 | 90 | span = spans['test_failures_and_errors.py::test_two'] 91 | assert not span.status.is_ok 92 | assert span.attributes 93 | assert span.attributes['code.function'] == 'test_two' 94 | assert span.attributes['code.filepath'] == 'test_failures_and_errors.py' 95 | assert span.attributes['code.lineno'] == 5 96 | assert 'exception.stacktrace' not in span.attributes 97 | assert len(span.events) == 1 98 | event = span.events[0] 99 | assert event.attributes 100 | assert event.attributes['exception.type'] == 'AssertionError' 101 | 102 | span = spans['test_failures_and_errors.py::test_three'] 103 | assert not span.status.is_ok 104 | assert span.attributes 105 | assert span.attributes['code.function'] == 'test_three' 106 | assert span.attributes['code.filepath'] == 'test_failures_and_errors.py' 107 | assert span.attributes['code.lineno'] == 8 108 | assert 'exception.stacktrace' not in span.attributes 109 | assert len(span.events) == 1 110 | event = span.events[0] 111 | assert event.attributes 112 | assert event.attributes['exception.type'] == 'ValueError' 113 | assert event.attributes['exception.message'] == 'woops' 114 | 115 | span = spans['test_failures_and_errors.py::test_four'] 116 | assert not span.status.is_ok 117 | assert span.attributes 118 | assert span.attributes['code.function'] == 'test_four' 119 | assert span.attributes['code.filepath'] == 'test_failures_and_errors.py' 120 | assert span.attributes['code.lineno'] == 11 121 | assert 'exception.stacktrace' not in span.attributes 122 | assert len(span.events) == 1 123 | event = span.events[0] 124 | assert event.attributes 125 | assert event.attributes['exception.type'] == 'Failed' 126 | assert event.attributes['exception.message'] == "DID NOT RAISE " 127 | 128 | 129 | def test_failures_in_fixtures(pytester: Pytester, span_recorder: SpanRecorder) -> None: 130 | pytester.makepyfile( 131 | """ 132 | import pytest 133 | 134 | @pytest.fixture 135 | def borked_fixture(): 136 | raise ValueError('newp') 137 | 138 | def test_one(): 139 | assert 1 + 2 == 3 140 | 141 | def test_two(borked_fixture): 142 | assert 2 + 2 == 5 143 | 144 | def test_three(borked_fixture): 145 | assert 2 + 2 == 4 146 | 147 | def test_four(): 148 | assert 2 + 2 == 5 149 | """ 150 | ) 151 | result = pytester.runpytest() 152 | result.assert_outcomes(passed=1, failed=1, errors=2) 153 | 154 | spans = span_recorder.spans_by_name() 155 | # assert len(spans) == 4 + 1 156 | 157 | assert 'test run' in spans 158 | 159 | span = spans['test_failures_in_fixtures.py::test_one'] 160 | assert span.status.is_ok 161 | 162 | span = spans['test_failures_in_fixtures.py::test_two'] 163 | assert not span.status.is_ok 164 | 165 | span = spans['test_failures_in_fixtures.py::test_three'] 166 | assert not span.status.is_ok 167 | 168 | span = spans['test_failures_in_fixtures.py::test_four'] 169 | assert not span.status.is_ok 170 | 171 | 172 | def test_parametrized_tests(pytester: Pytester, span_recorder: SpanRecorder) -> None: 173 | pytester.makepyfile( 174 | """ 175 | import pytest 176 | 177 | @pytest.mark.parametrize('hello', ['world', 'people']) 178 | def test_one(hello): 179 | assert 1 + 2 == 3 180 | 181 | def test_two(): 182 | assert 2 + 2 == 4 183 | """ 184 | ) 185 | pytester.runpytest().assert_outcomes(passed=3) 186 | 187 | spans = span_recorder.spans_by_name() 188 | # assert len(spans) == 3 + 1 189 | 190 | assert 'test run' in spans 191 | 192 | span = spans['test_parametrized_tests.py::test_one[world]'] 193 | assert span.status.is_ok 194 | assert span.attributes 195 | assert ( 196 | span.attributes["pytest.nodeid"] 197 | == "test_parametrized_tests.py::test_one[world]" 198 | ) 199 | 200 | span = spans['test_parametrized_tests.py::test_one[people]'] 201 | assert span.status.is_ok 202 | assert span.attributes 203 | assert ( 204 | span.attributes["pytest.nodeid"] 205 | == "test_parametrized_tests.py::test_one[people]" 206 | ) 207 | 208 | span = spans['test_parametrized_tests.py::test_two'] 209 | assert span.status.is_ok 210 | assert span.attributes 211 | assert span.attributes["pytest.nodeid"] == "test_parametrized_tests.py::test_two" 212 | 213 | 214 | def test_class_tests(pytester: Pytester, span_recorder: SpanRecorder) -> None: 215 | pytester.makepyfile( 216 | """ 217 | class TestThings: 218 | def test_one(self): 219 | assert 1 + 2 == 3 220 | 221 | def test_two(self): 222 | assert 2 + 2 == 4 223 | """ 224 | ) 225 | pytester.runpytest().assert_outcomes(passed=2) 226 | 227 | spans = span_recorder.spans_by_name() 228 | # assert len(spans) == 2 + 1 229 | 230 | assert 'test run' in spans 231 | 232 | span = spans['test_class_tests.py::TestThings::test_one'] 233 | assert span.status.is_ok 234 | assert span.attributes 235 | assert ( 236 | span.attributes["pytest.nodeid"] == "test_class_tests.py::TestThings::test_one" 237 | ) 238 | 239 | span = spans['test_class_tests.py::TestThings::test_two'] 240 | assert span.status.is_ok 241 | assert span.attributes 242 | assert ( 243 | span.attributes["pytest.nodeid"] == "test_class_tests.py::TestThings::test_two" 244 | ) 245 | 246 | 247 | def test_test_spans_are_children_of_sessions( 248 | pytester: Pytester, span_recorder: SpanRecorder 249 | ) -> None: 250 | pytester.makepyfile( 251 | """ 252 | def test_one(): 253 | assert 1 + 2 == 3 254 | """ 255 | ) 256 | pytester.runpytest().assert_outcomes(passed=1) 257 | 258 | spans = span_recorder.spans_by_name() 259 | # assert len(spans) == 2 260 | 261 | test_run = spans['test run'] 262 | test = spans['test_test_spans_are_children_of_sessions.py::test_one'] 263 | 264 | assert test_run.context.trace_id 265 | assert test.context.trace_id == test_run.context.trace_id 266 | 267 | assert test.parent 268 | assert test.parent.span_id == test_run.context.span_id 269 | 270 | 271 | def test_spans_within_tests_are_children_of_test_spans( 272 | pytester: Pytester, span_recorder: SpanRecorder 273 | ) -> None: 274 | pytester.makepyfile( 275 | """ 276 | from opentelemetry import trace 277 | 278 | tracer = trace.get_tracer('inside') 279 | 280 | def test_one(): 281 | with tracer.start_as_current_span('inner'): 282 | assert 1 + 2 == 3 283 | """ 284 | ) 285 | pytester.runpytest().assert_outcomes(passed=1) 286 | 287 | spans = span_recorder.spans_by_name() 288 | # assert len(spans) == 3 289 | 290 | test_run = spans['test run'] 291 | test = spans['test_spans_within_tests_are_children_of_test_spans.py::test_one'] 292 | test_call = spans[ 293 | 'test_spans_within_tests_are_children_of_test_spans.py::test_one::call' 294 | ] 295 | inner = spans['inner'] 296 | 297 | assert test_run.context.trace_id 298 | assert test.context.trace_id == test_run.context.trace_id 299 | assert test_call.context.trace_id == test.context.trace_id 300 | assert inner.context.trace_id == test_call.context.trace_id 301 | 302 | assert test.parent 303 | assert test.parent.span_id == test_run.context.span_id 304 | 305 | assert test_call.parent 306 | assert test_call.parent.span_id == test.context.span_id 307 | 308 | assert inner.parent 309 | assert inner.parent.span_id == test_call.context.span_id 310 | 311 | 312 | def test_spans_cover_setup_and_teardown( 313 | pytester: Pytester, span_recorder: SpanRecorder 314 | ) -> None: 315 | pytester.makepyfile( 316 | """ 317 | import pytest 318 | from opentelemetry import trace 319 | 320 | tracer = trace.get_tracer('inside') 321 | 322 | @pytest.fixture 323 | def yielded() -> int: 324 | with tracer.start_as_current_span('before'): 325 | pass 326 | 327 | with tracer.start_as_current_span('yielding'): 328 | yield 1 329 | 330 | with tracer.start_as_current_span('after'): 331 | pass 332 | 333 | @pytest.fixture 334 | def returned() -> int: 335 | with tracer.start_as_current_span('returning'): 336 | return 2 337 | 338 | def test_one(yielded: int, returned: int): 339 | with tracer.start_as_current_span('during'): 340 | assert yielded + returned == 3 341 | """ 342 | ) 343 | pytester.runpytest().assert_outcomes(passed=1) 344 | 345 | spans = span_recorder.spans_by_name() 346 | 347 | test_run = spans['test run'] 348 | assert test_run.context.trace_id 349 | assert all( 350 | span.context.trace_id == test_run.context.trace_id for span in spans.values() 351 | ) 352 | 353 | test = spans['test_spans_cover_setup_and_teardown.py::test_one'] 354 | 355 | setup = spans['test_spans_cover_setup_and_teardown.py::test_one::setup'] 356 | assert setup.parent.span_id == test.context.span_id 357 | 358 | assert spans['yielded setup'].parent.span_id == setup.context.span_id 359 | assert spans['returned setup'].parent.span_id == setup.context.span_id 360 | 361 | teardown = spans['test_spans_cover_setup_and_teardown.py::test_one::teardown'] 362 | assert teardown.parent.span_id == test.context.span_id 363 | assert spans['yielded teardown'].parent.span_id == teardown.context.span_id 364 | assert spans['returned teardown'].parent.span_id == teardown.context.span_id 365 | 366 | 367 | def test_spans_cover_fixtures_at_different_scopes( 368 | pytester: Pytester, span_recorder: SpanRecorder 369 | ) -> None: 370 | pytester.makepyfile( 371 | """ 372 | import pytest 373 | from opentelemetry import trace 374 | 375 | tracer = trace.get_tracer('inside') 376 | 377 | @pytest.fixture(scope='session') 378 | def session_scoped() -> int: 379 | return 1 380 | 381 | @pytest.fixture(scope='module') 382 | def module_scoped() -> int: 383 | return 2 384 | 385 | @pytest.fixture(scope='function') 386 | def function_scoped() -> int: 387 | return 3 388 | 389 | def test_one(session_scoped: int, module_scoped: int, function_scoped: int): 390 | assert session_scoped + module_scoped + function_scoped == 6 391 | """ 392 | ) 393 | pytester.runpytest().assert_outcomes(passed=1) 394 | 395 | spans = span_recorder.spans_by_name() 396 | 397 | test_run = spans['test run'] 398 | assert test_run.context.trace_id 399 | assert all( 400 | span.context.trace_id == test_run.context.trace_id for span in spans.values() 401 | ) 402 | 403 | test = spans['test_spans_cover_fixtures_at_different_scopes.py::test_one'] 404 | 405 | setup = spans['test_spans_cover_fixtures_at_different_scopes.py::test_one::setup'] 406 | assert setup.parent.span_id == test.context.span_id 407 | 408 | session_scoped = spans['session_scoped setup'] 409 | module_scoped = spans['module_scoped setup'] 410 | function_scoped = spans['function_scoped setup'] 411 | 412 | assert session_scoped.parent.span_id == setup.context.span_id 413 | assert module_scoped.parent.span_id == setup.context.span_id 414 | assert function_scoped.parent.span_id == setup.context.span_id 415 | 416 | 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 | ) 424 | def test_spans_from_fixutres_used_multiple_times( 425 | pytester: Pytester, 426 | span_recorder: SpanRecorder, 427 | args: List[str], 428 | ) -> None: 429 | pytester.makepyfile( 430 | """ 431 | import pytest 432 | from opentelemetry import trace 433 | 434 | tracer = trace.get_tracer('inside') 435 | 436 | @pytest.fixture(scope='session') 437 | def session_scoped() -> int: 438 | return 1 439 | 440 | @pytest.fixture(scope='module') 441 | def module_scoped() -> int: 442 | return 2 443 | 444 | @pytest.fixture(scope='function') 445 | def function_scoped() -> int: 446 | return 3 447 | 448 | @pytest.fixture(scope='class') 449 | def class_scoped() -> int: 450 | return 4 451 | 452 | def test_one(session_scoped: int, module_scoped: int, function_scoped: int): 453 | assert session_scoped + module_scoped + function_scoped == 6 454 | 455 | def test_two(session_scoped: int, module_scoped: int, function_scoped: int): 456 | assert session_scoped + module_scoped + function_scoped == 6 457 | 458 | class TestClass: 459 | def test_a(self, class_scoped: int, session_scoped: int): 460 | assert class_scoped + session_scoped == 5 461 | 462 | def test_b(self, class_scoped: int, module_scoped: int): 463 | assert class_scoped + module_scoped == 6 464 | 465 | def test_c(self, class_scoped: int, function_scoped: int): 466 | assert class_scoped + function_scoped == 7 467 | 468 | """ 469 | ) 470 | pytester.runpytest(*args).assert_outcomes(passed=5) 471 | spans = Counter(span.name for span in span_recorder.finished_spans()) 472 | 473 | assert spans['session_scoped setup'] == 1 474 | assert spans['session_scoped teardown'] == 1 475 | 476 | assert spans['module_scoped setup'] == 1 477 | assert spans['module_scoped teardown'] == 1 478 | 479 | assert spans['class_scoped setup'] == 1 480 | assert spans['class_scoped teardown'] == 1 481 | 482 | assert spans['function_scoped setup'] == 3 483 | assert spans['function_scoped teardown'] == 3 484 | 485 | 486 | def test_parametrized_fixture_names( 487 | pytester: Pytester, span_recorder: SpanRecorder 488 | ) -> None: 489 | pytester.makepyfile( 490 | """ 491 | import pytest 492 | from opentelemetry import trace 493 | 494 | class Nope: 495 | def __str__(self): 496 | raise ValueError('nope') 497 | 498 | @pytest.fixture(params=[111, 222]) 499 | def stringable(request) -> int: 500 | return request.param 501 | 502 | @pytest.fixture(params=[Nope(), Nope()]) 503 | def unstringable(request) -> Nope: 504 | return request.param 505 | 506 | def test_one(stringable: int, unstringable: Nope): 507 | assert isinstance(stringable, int) 508 | assert isinstance(unstringable, Nope) 509 | """ 510 | ) 511 | pytester.runpytest().assert_outcomes(passed=4) 512 | 513 | spans = span_recorder.spans_by_name() 514 | 515 | test_run = spans['test run'] 516 | assert test_run.context.trace_id 517 | assert all( 518 | span.context.trace_id == test_run.context.trace_id for span in spans.values() 519 | ) 520 | 521 | # the stringable arguments are used in the span name 522 | assert 'stringable[111] setup' in spans 523 | assert 'stringable[111] teardown' in spans 524 | assert 'stringable[222] setup' in spans 525 | assert 'stringable[222] teardown' in spans 526 | 527 | # the indexes of non-stringable arguments are used in the span name 528 | assert 'unstringable[0] setup' in spans 529 | assert 'unstringable[0] teardown' in spans 530 | assert 'unstringable[1] setup' in spans 531 | assert 'unstringable[1] teardown' in spans 532 | --------------------------------------------------------------------------------