diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 25a0a06e4b..2a192de85b 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -12,6 +12,9 @@ components: instrumentation/opentelemetry-instrumentation-asyncio: - bourbonkk + instrumentation/opentelemetry-instrumentation-exceptions: + - iblancasa + instrumentation/opentelemetry-instrumentation-botocore: - lukeina2z - yiyuan-he diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml index d9f90d57d8..43d0625931 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -1827,6 +1827,36 @@ jobs: - name: Run tests run: tox -e py310-test-instrumentation-logging -- -ra + py310-test-instrumentation-exceptions: + name: instrumentation-exceptions + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-exceptions -- -ra + py310-test-exporter-richconsole: name: exporter-richconsole runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6bf3c631cd..8ef635106c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -613,6 +613,25 @@ jobs: - name: Run tests run: tox -e lint-instrumentation-logging + lint-instrumentation-exceptions: + name: instrumentation-exceptions + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e lint-instrumentation-exceptions + lint-exporter-richconsole: name: exporter-richconsole runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c694007ea..19520e3a99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6142,6 +6142,120 @@ jobs: - name: Run tests run: tox -e pypy3-test-instrumentation-logging -- -ra + py310-test-instrumentation-exceptions_ubuntu-latest: + name: instrumentation-exceptions 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-exceptions -- -ra + + py311-test-instrumentation-exceptions_ubuntu-latest: + name: instrumentation-exceptions 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-exceptions -- -ra + + py312-test-instrumentation-exceptions_ubuntu-latest: + name: instrumentation-exceptions 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-exceptions -- -ra + + py313-test-instrumentation-exceptions_ubuntu-latest: + name: instrumentation-exceptions 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-exceptions -- -ra + + py314-test-instrumentation-exceptions_ubuntu-latest: + name: instrumentation-exceptions 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-exceptions -- -ra + + pypy3-test-instrumentation-exceptions_ubuntu-latest: + name: instrumentation-exceptions pypy-3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python pypy-3.10 + uses: actions/setup-python@v5 + with: + python-version: "pypy-3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e pypy3-test-instrumentation-exceptions -- -ra + py310-test-exporter-richconsole_ubuntu-latest: name: exporter-richconsole 3.10 Ubuntu runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f0cab4fc..0d3d1ed7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4335](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4335)) - Expand `AGENTS.md` with instrumentation/GenAI guidance and add PR review instructions. ([#4457](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4457)) +- `opentelemetry-instrumentation-exceptions`: Add instrumentation to emit OpenTelemetry logs for uncaught exceptions, uncaught thread exceptions, and unhandled asyncio task exceptions. + ([#4209](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/4209)) ### Fixed diff --git a/docs/instrumentation/exceptions/exceptions.rst b/docs/instrumentation/exceptions/exceptions.rst new file mode 100644 index 0000000000..4ae46f16e6 --- /dev/null +++ b/docs/instrumentation/exceptions/exceptions.rst @@ -0,0 +1,7 @@ +OpenTelemetry Exceptions Instrumentation +======================================== + +.. automodule:: opentelemetry.instrumentation.exceptions + :members: + :undoc-members: + :show-inheritance: diff --git a/instrumentation/README.md b/instrumentation/README.md index 8d9a247945..a430f60364 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -20,6 +20,7 @@ | [opentelemetry-instrumentation-dbapi](./opentelemetry-instrumentation-dbapi) | dbapi | No | development | [opentelemetry-instrumentation-django](./opentelemetry-instrumentation-django) | django >= 2.0 | Yes | development | [opentelemetry-instrumentation-elasticsearch](./opentelemetry-instrumentation-elasticsearch) | elasticsearch >= 6.0 | No | development +| [opentelemetry-instrumentation-exceptions](./opentelemetry-instrumentation-exceptions) | exceptions | No | development | [opentelemetry-instrumentation-falcon](./opentelemetry-instrumentation-falcon) | falcon >= 1.4.1, < 5.0.0 | Yes | migration | [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.92 | Yes | migration | [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0 | Yes | migration diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/LICENSE b/instrumentation/opentelemetry-instrumentation-exceptions/LICENSE new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/LICENSE @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/README.rst b/instrumentation/opentelemetry-instrumentation-exceptions/README.rst new file mode 100644 index 0000000000..f73f1af236 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/README.rst @@ -0,0 +1,35 @@ +OpenTelemetry unhandled exceptions instrumentation +================================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-exceptions.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-exceptions/ + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-exceptions + +Usage +----- + +.. code-block:: python + + from opentelemetry.instrumentation.exceptions import ( + UnhandledExceptionInstrumentor, + ) + + UnhandledExceptionInstrumentor().instrument() + +This instrumentation captures uncaught process exceptions, uncaught thread +exceptions, and unhandled asyncio task exceptions and emits them as OpenTelemetry +logs. + +References +---------- + +* `OpenTelemetry Python Contrib repository `_ +* `OpenTelemetry Project `_ diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/pyproject.toml b/instrumentation/opentelemetry-instrumentation-exceptions/pyproject.toml new file mode 100644 index 0000000000..3fe7d47c14 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-exceptions" +dynamic = ["version"] +description = "OpenTelemetry unhandled exceptions instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api >= 1.42.0.dev, < 2.0.0", + "opentelemetry-instrumentation == 0.63b0.dev", + "opentelemetry-semantic-conventions == 0.63b0.dev", +] + +[project.optional-dependencies] +instruments = [] + +[project.entry-points.opentelemetry_instrumentor] +exceptions = "opentelemetry.instrumentation.exceptions:UnhandledExceptionInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-exceptions" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/exceptions/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/__init__.py b/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/__init__.py new file mode 100644 index 0000000000..e019425f33 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/__init__.py @@ -0,0 +1,204 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Instrument uncaught exceptions to emit OpenTelemetry logs. + +Usage +----- + +.. code-block:: python + + from opentelemetry.instrumentation.exceptions import ( + UnhandledExceptionInstrumentor, + ) + + UnhandledExceptionInstrumentor().instrument() + +This instrumentation captures uncaught process exceptions, uncaught thread +exceptions, and unhandled asyncio task exceptions and emits them as +OpenTelemetry logs. +""" + +from __future__ import annotations + +import asyncio +import sys +import threading +from collections.abc import Collection +from types import TracebackType +from typing import Any + +from wrapt import ( + wrap_function_wrapper, # type: ignore[reportUnknownVariableType] +) + +from opentelemetry._logs import LoggerProvider, SeverityNumber, get_logger +from opentelemetry.instrumentation.exceptions.package import _instruments +from opentelemetry.instrumentation.exceptions.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.semconv.schemas import Schemas + + +class _ExceptionLogger: + def __init__(self, logger_provider: LoggerProvider | None = None): + self._logger = get_logger( + __name__, + __version__, + logger_provider, + schema_url=Schemas.V1_37_0.value, + ) + + def emit( + self, + exc: Exception, + *, + severity_text: str, + severity_number: SeverityNumber, + ) -> None: + self._logger.emit( + body=str(exc), + severity_text=severity_text, + severity_number=severity_number, + exception=exc, + ) + + +class UnhandledExceptionInstrumentor(BaseInstrumentor): + """Emit logs for uncaught exceptions and unhandled asyncio exceptions.""" + + def __init__(self, logger_provider: LoggerProvider | None = None): + super().__init__() + self._logger_provider = logger_provider + self._exception_logger: _ExceptionLogger | None = None + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Any): + logger_provider = kwargs.get("logger_provider", self._logger_provider) + self._exception_logger = _ExceptionLogger(logger_provider) + self._install_sys_hook() + self._install_threading_hook() + self._install_asyncio_hook() + + def _uninstrument(self, **kwargs: Any): + self._restore_sys_hook() + self._restore_threading_hook() + self._restore_asyncio_hook() + self._exception_logger = None + + def _install_sys_hook(self) -> None: + wrap_function_wrapper( + sys, + "excepthook", + self._wrap_sys_excepthook, + ) + + @staticmethod + def _restore_sys_hook() -> None: + unwrap(sys, "excepthook") + + def _install_threading_hook(self) -> None: + wrap_function_wrapper( + threading, + "excepthook", + self._wrap_threading_excepthook, + ) + + @staticmethod + def _restore_threading_hook() -> None: + unwrap(threading, "excepthook") + + def _install_asyncio_hook(self) -> None: + wrap_function_wrapper( + asyncio.BaseEventLoop, + "call_exception_handler", + self._wrap_asyncio_call_exception_handler, + ) + + @staticmethod + def _restore_asyncio_hook() -> None: + unwrap(asyncio.BaseEventLoop, "call_exception_handler") + + def _emit_exception( + self, + exc: BaseException, + *, + severity_text: str, + severity_number: SeverityNumber, + ) -> None: + if not isinstance(exc, Exception) or self._exception_logger is None: + return + + try: + self._exception_logger.emit( + exc, + severity_text=severity_text, + severity_number=severity_number, + ) + # Logging must never replace the original unhandled exception path. + # pylint: disable-next=broad-exception-caught + except Exception: # pragma: no cover + pass + + def _wrap_sys_excepthook( + self, + wrapped, + instance, + args: tuple[type[BaseException], BaseException, TracebackType | None], + kwargs: dict[str, Any], + ) -> None: + _, exc, _ = args + self._emit_exception( + exc, + severity_text="FATAL", + severity_number=SeverityNumber.FATAL, + ) + wrapped(*args, **kwargs) + + def _wrap_threading_excepthook( + self, + wrapped, + instance, + args: tuple[threading.ExceptHookArgs], + kwargs: dict[str, Any], + ) -> None: + (hook_args,) = args + self._emit_exception( + hook_args.exc_value, + severity_text="ERROR", + severity_number=SeverityNumber.ERROR, + ) + wrapped(*args, **kwargs) + + def _wrap_asyncio_call_exception_handler( + self, + wrapped, + instance: asyncio.AbstractEventLoop, + args: tuple[dict[str, Any]], + kwargs: dict[str, Any], + ) -> None: + (context,) = args + exc = context.get("exception") + if isinstance(exc, BaseException): + self._emit_exception( + exc, + severity_text="ERROR", + severity_number=SeverityNumber.ERROR, + ) + wrapped(*args, **kwargs) + + +__all__ = ["UnhandledExceptionInstrumentor"] diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/package.py b/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/package.py new file mode 100644 index 0000000000..1bf177779b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/package.py @@ -0,0 +1,17 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +_instruments = () + +_supports_metrics = False diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/version.py b/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/version.py new file mode 100644 index 0000000000..a07bc2663e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/src/opentelemetry/instrumentation/exceptions/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.63b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-exceptions/test-requirements.txt new file mode 100644 index 0000000000..ba0e2e83ff --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/test-requirements.txt @@ -0,0 +1,13 @@ +asgiref==3.8.1 +Deprecated==1.2.14 +iniconfig==2.0.0 +packaging==24.0 +pluggy==1.6.0 +py-cpuinfo==9.0.0 +pytest==7.4.4 +tomli==2.0.1 +typing_extensions==4.12.2 +wrapt==1.16.0 +zipp==3.19.2 +-e opentelemetry-instrumentation +-e instrumentation/opentelemetry-instrumentation-exceptions diff --git a/instrumentation/opentelemetry-instrumentation-exceptions/tests/test_exceptions.py b/instrumentation/opentelemetry-instrumentation-exceptions/tests/test_exceptions.py new file mode 100644 index 0000000000..d78fcc986e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-exceptions/tests/test_exceptions.py @@ -0,0 +1,248 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import sys +import threading +from collections.abc import Generator + +import pytest + +import opentelemetry._logs._internal +from opentelemetry._logs import ( + SeverityNumber, + get_logger_provider, + set_logger_provider, +) +from opentelemetry.instrumentation.exceptions import ( + UnhandledExceptionInstrumentor, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.semconv.attributes import exception_attributes +from opentelemetry.util._once import Once + +try: + from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, + ) +except ImportError: + from opentelemetry.sdk._logs.export import ( + InMemoryLogExporter as InMemoryLogRecordExporter, + ) + from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor + +# pylint: disable=redefined-outer-name + + +@pytest.fixture +def log_exporter() -> Generator[InMemoryLogRecordExporter, None, None]: + snapshot = get_logger_provider() + opentelemetry._logs._internal._LOGGER_PROVIDER_SET_ONCE = Once() + provider = LoggerProvider() + exporter = InMemoryLogRecordExporter() + provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) + set_logger_provider(provider) + try: + yield exporter + finally: + opentelemetry._logs._internal._LOGGER_PROVIDER_SET_ONCE = Once() + set_logger_provider(snapshot) + + +@pytest.fixture +def instrumentor() -> Generator[UnhandledExceptionInstrumentor, None, None]: + inst = UnhandledExceptionInstrumentor() + try: + yield inst + finally: + inst.uninstrument() + + +def _raised_value_error() -> ValueError: + try: + raise ValueError("boom") + except ValueError as exc: + return exc + + +def _finished_log(log_exporter: InMemoryLogRecordExporter): + logs = log_exporter.get_finished_logs() + assert len(logs) == 1 + return logs[0].log_record + + +def test_sys_excepthook_emits_log( + log_exporter: InMemoryLogRecordExporter, + monkeypatch: pytest.MonkeyPatch, + instrumentor: UnhandledExceptionInstrumentor, +) -> None: + called = {"value": False} + + def stub_excepthook(exc_type, exc, tb) -> None: + called["value"] = True + + monkeypatch.setattr(sys, "excepthook", stub_excepthook) + instrumentor.instrument() + + exc = _raised_value_error() + sys.excepthook(type(exc), exc, exc.__traceback__) + + log_record = _finished_log(log_exporter) + assert log_record.severity_text == "FATAL" + assert log_record.severity_number == SeverityNumber.FATAL + assert log_record.body == "boom" + assert called["value"] is True + + attributes = log_record.attributes + assert attributes[exception_attributes.EXCEPTION_TYPE] == "ValueError" + assert isinstance(attributes[exception_attributes.EXCEPTION_TYPE], str) + assert attributes[exception_attributes.EXCEPTION_MESSAGE] == "boom" + assert isinstance(attributes[exception_attributes.EXCEPTION_MESSAGE], str) + assert ( + "ValueError: boom" + in attributes[exception_attributes.EXCEPTION_STACKTRACE] + ) + assert isinstance( + attributes[exception_attributes.EXCEPTION_STACKTRACE], str + ) + + +def test_threading_excepthook_emits_log( + log_exporter: InMemoryLogRecordExporter, + monkeypatch: pytest.MonkeyPatch, + instrumentor: UnhandledExceptionInstrumentor, +) -> None: + called = {"value": False} + + def stub_threading_excepthook(args: threading.ExceptHookArgs) -> None: + called["value"] = True + + monkeypatch.setattr(threading, "excepthook", stub_threading_excepthook) + instrumentor.instrument() + + exc = _raised_value_error() + args = threading.ExceptHookArgs( + (type(exc), exc, exc.__traceback__, threading.current_thread()) + ) + threading.excepthook(args) + + log_record = _finished_log(log_exporter) + assert log_record.severity_text == "ERROR" + assert log_record.severity_number == SeverityNumber.ERROR + assert log_record.body == "boom" + assert called["value"] is True + + attributes = log_record.attributes + assert attributes[exception_attributes.EXCEPTION_TYPE] == "ValueError" + assert attributes[exception_attributes.EXCEPTION_MESSAGE] == "boom" + assert ( + "ValueError: boom" + in attributes[exception_attributes.EXCEPTION_STACKTRACE] + ) + + +def test_asyncio_unhandled_exception_emits_log( + log_exporter: InMemoryLogRecordExporter, + monkeypatch: pytest.MonkeyPatch, + instrumentor: UnhandledExceptionInstrumentor, +) -> None: + called = {"value": False} + + original_handler = asyncio.BaseEventLoop.call_exception_handler + + def stub_call_exception_handler(loop, context) -> None: + called["value"] = True + original_handler(loop, context) + + monkeypatch.setattr( + asyncio.BaseEventLoop, + "call_exception_handler", + stub_call_exception_handler, + ) + instrumentor.instrument() + + loop = asyncio.new_event_loop() + loop.set_exception_handler(lambda _loop, _context: None) + try: + exc = _raised_value_error() + loop.call_exception_handler({"exception": exc, "message": "boom"}) + finally: + loop.close() + + log_record = _finished_log(log_exporter) + assert log_record.severity_text == "ERROR" + assert log_record.severity_number == SeverityNumber.ERROR + assert log_record.body == "boom" + assert called["value"] is True + + attributes = log_record.attributes + assert attributes[exception_attributes.EXCEPTION_TYPE] == "ValueError" + assert attributes[exception_attributes.EXCEPTION_MESSAGE] == "boom" + assert ( + "ValueError: boom" + in attributes[exception_attributes.EXCEPTION_STACKTRACE] + ) + + +def test_base_exceptions_are_not_emitted( + log_exporter: InMemoryLogRecordExporter, + monkeypatch: pytest.MonkeyPatch, + instrumentor: UnhandledExceptionInstrumentor, +) -> None: + called = {"value": False} + + def stub_excepthook(exc_type, exc, tb) -> None: + called["value"] = True + + monkeypatch.setattr(sys, "excepthook", stub_excepthook) + instrumentor.instrument() + + exc = KeyboardInterrupt() + sys.excepthook(type(exc), exc, exc.__traceback__) + + assert not log_exporter.get_finished_logs() + assert called["value"] is True + + +def test_uninstrument_restores_hooks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + instrumentor = UnhandledExceptionInstrumentor() + + def original_sys(exc_type, exc, tb) -> None: + del exc_type, exc, tb + + def original_threading(args: threading.ExceptHookArgs) -> None: + del args + + def original_asyncio(loop, context) -> None: + del loop, context + + monkeypatch.setattr(sys, "excepthook", original_sys) + monkeypatch.setattr(threading, "excepthook", original_threading) + monkeypatch.setattr( + asyncio.BaseEventLoop, + "call_exception_handler", + original_asyncio, + ) + + instrumentor.instrument(logger_provider=LoggerProvider()) + instrumentor.uninstrument() + + assert sys.excepthook is original_sys + assert threading.excepthook is original_threading + assert asyncio.BaseEventLoop.call_exception_handler is original_asyncio diff --git a/opentelemetry-contrib-instrumentations/pyproject.toml b/opentelemetry-contrib-instrumentations/pyproject.toml index 630509a4b2..3ba7ea0501 100644 --- a/opentelemetry-contrib-instrumentations/pyproject.toml +++ b/opentelemetry-contrib-instrumentations/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "opentelemetry-instrumentation-dbapi==0.63b0.dev", "opentelemetry-instrumentation-django==0.63b0.dev", "opentelemetry-instrumentation-elasticsearch==0.63b0.dev", + "opentelemetry-instrumentation-exceptions==0.63b0.dev", "opentelemetry-instrumentation-falcon==0.63b0.dev", "opentelemetry-instrumentation-fastapi==0.63b0.dev", "opentelemetry-instrumentation-flask==0.63b0.dev", diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 999181bd9f..8be6eecf5d 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -216,6 +216,7 @@ default_instrumentations = [ "opentelemetry-instrumentation-asyncio==0.63b0.dev", "opentelemetry-instrumentation-dbapi==0.63b0.dev", + "opentelemetry-instrumentation-exceptions==0.63b0.dev", "opentelemetry-instrumentation-logging==0.63b0.dev", "opentelemetry-instrumentation-sqlite3==0.63b0.dev", "opentelemetry-instrumentation-threading==0.63b0.dev", diff --git a/tox.ini b/tox.ini index 1f2ca7f8e9..e236adbf64 100644 --- a/tox.ini +++ b/tox.ini @@ -205,6 +205,11 @@ envlist = lint-instrumentation-logging benchmark-instrumentation-logging + ; opentelemetry-instrumentation-exceptions + py3{10,11,12,13,14}-test-instrumentation-exceptions + pypy3-test-instrumentation-exceptions + lint-instrumentation-exceptions + ; opentelemetry-exporter-richconsole py3{10,11,12,13,14}-test-exporter-richconsole pypy3-test-exporter-richconsole @@ -695,6 +700,9 @@ deps = logging: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt benchmark-instrumentation-logging: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/benchmark-requirements.txt + exceptions: {[testenv]test_deps} + exceptions: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-exceptions/test-requirements.txt + aiohttp-client: {[testenv]test_deps} aiohttp-client: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-aiohttp-client/test-requirements.txt @@ -897,6 +905,9 @@ commands = lint-instrumentation-logging: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-logging" benchmark-instrumentation-logging: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/benchmarks {posargs} --benchmark-json=instrumentation-logging-benchmark.json + test-instrumentation-exceptions: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-exceptions/tests {posargs} + lint-instrumentation-exceptions: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-exceptions" + test-instrumentation-mysql: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-mysql/tests {posargs} lint-instrumentation-mysql: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-mysql" diff --git a/uv.lock b/uv.lock index 14d5ea35b6..8d0e824c7b 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,7 @@ members = [ "opentelemetry-instrumentation-dbapi", "opentelemetry-instrumentation-django", "opentelemetry-instrumentation-elasticsearch", + "opentelemetry-instrumentation-exceptions", "opentelemetry-instrumentation-falcon", "opentelemetry-instrumentation-fastapi", "opentelemetry-instrumentation-flask", @@ -3315,6 +3316,23 @@ requires-dist = [ ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-exceptions" +source = { editable = "instrumentation/opentelemetry-instrumentation-exceptions" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, + { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-falcon" source = { editable = "instrumentation/opentelemetry-instrumentation-falcon" }