diff --git a/.gitignore b/.gitignore index 8e4c4ae0bc..0c0cf060f8 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,9 @@ __pypackages__/ certs/ pdm.toml .zed +.claude +.gemini +specs/ +CLAUDE.md +AGENTS.md +GEMINI.md diff --git a/docs/reference/contrib/opentelemetry.rst b/docs/reference/contrib/opentelemetry.rst index 0450df4d5b..3f2fde30cd 100644 --- a/docs/reference/contrib/opentelemetry.rst +++ b/docs/reference/contrib/opentelemetry.rst @@ -1,6 +1,10 @@ opentelemetry ============= +.. deprecated:: 2.18.0 + The ``litestar.contrib.opentelemetry`` module is deprecated and will be removed in version 3.0.0. + Please use :doc:`litestar.plugins.opentelemetry ` instead. + .. automodule:: litestar.contrib.opentelemetry :members: diff --git a/docs/reference/plugins/index.rst b/docs/reference/plugins/index.rst index cd075431f6..b3c3ed687c 100644 --- a/docs/reference/plugins/index.rst +++ b/docs/reference/plugins/index.rst @@ -12,6 +12,7 @@ plugins attrs flash_messages htmx + opentelemetry problem_details prometheus pydantic diff --git a/docs/reference/plugins/opentelemetry.rst b/docs/reference/plugins/opentelemetry.rst new file mode 100644 index 0000000000..f6d40c63be --- /dev/null +++ b/docs/reference/plugins/opentelemetry.rst @@ -0,0 +1,7 @@ +opentelemetry +============= + +.. automodule:: litestar.plugins.opentelemetry + :members: + +.. autoclass:: litestar.plugins.opentelemetry.config.OpenTelemetryHookHandler diff --git a/docs/usage/metrics/open-telemetry.rst b/docs/usage/metrics/open-telemetry.rst index 1c349736f5..bee92c6457 100644 --- a/docs/usage/metrics/open-telemetry.rst +++ b/docs/usage/metrics/open-telemetry.rst @@ -1,7 +1,7 @@ OpenTelemetry ============= -Litestar includes optional OpenTelemetry instrumentation that is exported from ``litestar.contrib.opentelemetry``. To use +Litestar includes optional OpenTelemetry instrumentation that is exported from ``litestar.plugins.opentelemetry``. To use this package, you should first install the required dependencies: .. code-block:: bash @@ -16,13 +16,13 @@ this package, you should first install the required dependencies: pip install 'litestar[opentelemetry]' Once these requirements are satisfied, you can instrument your Litestar application by creating an instance -of :class:`OpenTelemetryConfig ` and passing the middleware it creates to +of :class:`OpenTelemetryConfig ` and passing the middleware it creates to the Litestar constructor: .. code-block:: python from litestar import Litestar - from litestar.contrib.opentelemetry import OpenTelemetryConfig, OpenTelemetryPlugin + from litestar.plugins.opentelemetry import OpenTelemetryConfig, OpenTelemetryPlugin open_telemetry_config = OpenTelemetryConfig() @@ -32,5 +32,5 @@ The above example will work out of the box if you configure a global ``tracer_pr exporter to use these (see the `OpenTelemetry Exporter docs `_ for further details). -You can also pass con figuration to the ``OpenTelemetryConfig`` telling it which providers to use. Consult -:class:`reference docs ` regarding the configuration options you can use. +You can also pass configuration to the ``OpenTelemetryConfig`` telling it which providers to use. Consult +:class:`reference docs ` regarding the configuration options you can use. diff --git a/litestar/contrib/opentelemetry/__init__.py b/litestar/contrib/opentelemetry/__init__.py index 983b777754..c84b3fa351 100644 --- a/litestar/contrib/opentelemetry/__init__.py +++ b/litestar/contrib/opentelemetry/__init__.py @@ -1,9 +1,46 @@ -from .config import OpenTelemetryConfig -from .middleware import OpenTelemetryInstrumentationMiddleware -from .plugin import OpenTelemetryPlugin +from __future__ import annotations + +from typing import TYPE_CHECKING + +from litestar.utils import warn_deprecation + +warn_deprecation( + deprecated_name="litestar.contrib.opentelemetry", + version="2.18.0", + kind="import", + removal_in="3.0.0", + info="The 'litestar.contrib.opentelemetry' module is deprecated. " + "Please import from 'litestar.plugins.opentelemetry' instead.", +) __all__ = ( "OpenTelemetryConfig", "OpenTelemetryInstrumentationMiddleware", "OpenTelemetryPlugin", ) + + +def __getattr__(attr_name: str) -> object: + if attr_name in __all__: + from litestar.plugins import opentelemetry + + value = globals()[attr_name] = getattr(opentelemetry, attr_name) + warn_deprecation( + deprecated_name=f"litestar.contrib.opentelemetry.{attr_name}", + version="2.18.0", + kind="import", + removal_in="3.0.0", + info=f"importing {attr_name} from 'litestar.contrib.opentelemetry' is deprecated, " + f"import from 'litestar.plugins.opentelemetry' instead", + ) + return value + + raise AttributeError(f"module {__name__!r} has no attribute {attr_name!r}") # pragma: no cover + + +if TYPE_CHECKING: + from litestar.plugins.opentelemetry import ( + OpenTelemetryConfig, + OpenTelemetryInstrumentationMiddleware, + OpenTelemetryPlugin, + ) diff --git a/litestar/plugins/opentelemetry/__init__.py b/litestar/plugins/opentelemetry/__init__.py new file mode 100644 index 0000000000..983b777754 --- /dev/null +++ b/litestar/plugins/opentelemetry/__init__.py @@ -0,0 +1,9 @@ +from .config import OpenTelemetryConfig +from .middleware import OpenTelemetryInstrumentationMiddleware +from .plugin import OpenTelemetryPlugin + +__all__ = ( + "OpenTelemetryConfig", + "OpenTelemetryInstrumentationMiddleware", + "OpenTelemetryPlugin", +) diff --git a/litestar/plugins/opentelemetry/_utils.py b/litestar/plugins/opentelemetry/_utils.py new file mode 100644 index 0000000000..66ba442c9a --- /dev/null +++ b/litestar/plugins/opentelemetry/_utils.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from litestar.exceptions import MissingDependencyException + +__all__ = ("get_route_details_from_scope",) + + +try: + import opentelemetry # noqa: F401 +except ImportError as e: + raise MissingDependencyException("opentelemetry") from e + +from opentelemetry.semconv.trace import SpanAttributes + +if TYPE_CHECKING: + from litestar.types import Scope + + +def get_route_details_from_scope(scope: Scope) -> tuple[str, dict[Any, str]]: + """Retrieve the span name and attributes from the ASGI scope. + + Args: + scope: The ASGI scope instance. + + Returns: + A tuple of the span name and a dict of attrs. + """ + + path = scope.get("path", "").strip() + method = str(scope.get("method", "")).strip() + + if method and path: # http + return f"{method} {path}", {SpanAttributes.HTTP_ROUTE: f"{method} {path}"} + + return path, {SpanAttributes.HTTP_ROUTE: path} # websocket diff --git a/litestar/plugins/opentelemetry/config.py b/litestar/plugins/opentelemetry/config.py new file mode 100644 index 0000000000..3371f519c7 --- /dev/null +++ b/litestar/plugins/opentelemetry/config.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable + +from litestar.exceptions import MissingDependencyException +from litestar.middleware.base import DefineMiddleware +from litestar.plugins.opentelemetry._utils import get_route_details_from_scope +from litestar.plugins.opentelemetry.middleware import ( + OpenTelemetryInstrumentationMiddleware, +) + +__all__ = ("OpenTelemetryConfig",) + + +try: + import opentelemetry # noqa: F401 +except ImportError as e: + raise MissingDependencyException("opentelemetry") from e + + +from opentelemetry.trace import Span, TracerProvider # pyright: ignore + +if TYPE_CHECKING: + from opentelemetry.metrics import Meter, MeterProvider + + from litestar.types import Scope, Scopes + +OpenTelemetryHookHandler = Callable[[Span, dict], None] + + +@dataclass +class OpenTelemetryConfig: + """Configuration class for the OpenTelemetry middleware. + + Consult the [OpenTelemetry ASGI documentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html) for more info about the configuration options. + """ + + scope_span_details_extractor: Callable[[Scope], tuple[str, dict[str, Any]]] = field( + default=get_route_details_from_scope + ) + """Callback which should return a string and a tuple, representing the desired default span name and a dictionary + with any additional span attributes to set. + """ + server_request_hook_handler: OpenTelemetryHookHandler | None = field(default=None) + """Optional callback which is called with the server span and ASGI scope object for every incoming request.""" + client_request_hook_handler: OpenTelemetryHookHandler | None = field(default=None) + """Optional callback which is called with the internal span and an ASGI scope which is sent as a dictionary for when + the method receive is called. + """ + client_response_hook_handler: OpenTelemetryHookHandler | None = field(default=None) + """Optional callback which is called with the internal span and an ASGI event which is sent as a dictionary for when + the method send is called. + """ + meter_provider: MeterProvider | None = field(default=None) + """Optional meter provider to use. + + If omitted the current globally configured one is used. + """ + tracer_provider: TracerProvider | None = field(default=None) + """Optional tracer provider to use. + + If omitted the current globally configured one is used. + """ + meter: Meter | None = field(default=None) + """Optional meter to use. + + If omitted the provided meter provider or the global one will be used. + """ + exclude: str | list[str] | None = field(default=None) + """A pattern or list of patterns to skip in the Allowed Hosts middleware.""" + exclude_opt_key: str | None = field(default=None) + """An identifier to use on routes to disable hosts check for a particular route.""" + exclude_urls_env_key: str = "LITESTAR" + """Key to use when checking whether a list of excluded urls is passed via ENV. + + OpenTelemetry supports excluding urls by passing an env in the format '{exclude_urls_env_key}_EXCLUDED_URLS'. With + the default being ``LITESTAR_EXCLUDED_URLS``. + """ + scopes: Scopes | None = field(default=None) + """ASGI scopes processed by the middleware, if None both ``http`` and ``websocket`` will be processed.""" + middleware_class: type[OpenTelemetryInstrumentationMiddleware] = field( + default=OpenTelemetryInstrumentationMiddleware + ) + """The middleware class to use. + + Should be a subclass of OpenTelemetry + InstrumentationMiddleware][litestar.plugins.opentelemetry.OpenTelemetryInstrumentationMiddleware]. + """ + + @property + def middleware(self) -> DefineMiddleware: + """Create an instance of :class:`DefineMiddleware ` that wraps with. + + [OpenTelemetry + InstrumentationMiddleware][litestar.plugins.opentelemetry.OpenTelemetryInstrumentationMiddleware] or a subclass + of this middleware. + + Returns: + An instance of ``DefineMiddleware``. + """ + return DefineMiddleware(self.middleware_class, config=self) diff --git a/litestar/plugins/opentelemetry/middleware.py b/litestar/plugins/opentelemetry/middleware.py new file mode 100644 index 0000000000..c43ab65337 --- /dev/null +++ b/litestar/plugins/opentelemetry/middleware.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from litestar.exceptions import MissingDependencyException +from litestar.middleware.base import AbstractMiddleware + +__all__ = ("OpenTelemetryInstrumentationMiddleware",) + + +try: + import opentelemetry # noqa: F401 +except ImportError as e: + raise MissingDependencyException("opentelemetry") from e + +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware +from opentelemetry.util.http import get_excluded_urls + +if TYPE_CHECKING: + from litestar.plugins.opentelemetry import OpenTelemetryConfig + from litestar.types import ASGIApp, Receive, Scope, Send + + +class OpenTelemetryInstrumentationMiddleware(AbstractMiddleware): + """OpenTelemetry Middleware.""" + + def __init__(self, app: ASGIApp, config: OpenTelemetryConfig) -> None: + """Middleware that adds OpenTelemetry instrumentation to the application. + + Args: + app: The ``next`` ASGI app to call. + config: An instance of :class:`OpenTelemetryConfig <.plugins.opentelemetry.OpenTelemetryConfig>` + """ + super().__init__(app=app, scopes=config.scopes, exclude=config.exclude, exclude_opt_key=config.exclude_opt_key) + self.open_telemetry_middleware = OpenTelemetryMiddleware( + app=app, + client_request_hook=config.client_request_hook_handler, # type: ignore[arg-type] + client_response_hook=config.client_response_hook_handler, # type: ignore[arg-type] + default_span_details=config.scope_span_details_extractor, + excluded_urls=get_excluded_urls(config.exclude_urls_env_key), + meter=config.meter, + meter_provider=config.meter_provider, + server_request_hook=config.server_request_hook_handler, + tracer_provider=config.tracer_provider, + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ASGI callable. + + Args: + scope: The ASGI connection scope. + receive: The ASGI receive function. + send: The ASGI send function. + + Returns: + None + """ + await self.open_telemetry_middleware(scope, receive, send) # type: ignore[arg-type] # pyright: ignore[reportGeneralTypeIssues] diff --git a/litestar/plugins/opentelemetry/plugin.py b/litestar/plugins/opentelemetry/plugin.py new file mode 100644 index 0000000000..75d3177dcc --- /dev/null +++ b/litestar/plugins/opentelemetry/plugin.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from litestar.middleware.base import DefineMiddleware +from litestar.plugins import InitPlugin +from litestar.plugins.opentelemetry.config import OpenTelemetryConfig +from litestar.plugins.opentelemetry.middleware import OpenTelemetryInstrumentationMiddleware + +if TYPE_CHECKING: + from litestar.config.app import AppConfig + from litestar.types.composite_types import Middleware + + +class OpenTelemetryPlugin(InitPlugin): + """OpenTelemetry Plugin.""" + + __slots__ = ("_middleware", "config") + + def __init__(self, config: OpenTelemetryConfig | None = None) -> None: + self.config = config or OpenTelemetryConfig() + self._middleware: DefineMiddleware | None = None + super().__init__() + + @property + def middleware(self) -> DefineMiddleware: + if self._middleware: + return self._middleware + return DefineMiddleware(OpenTelemetryInstrumentationMiddleware, config=self.config) + + def on_app_init(self, app_config: AppConfig) -> AppConfig: + app_config.middleware, _middleware = self._pop_otel_middleware(app_config.middleware) + return app_config + + @staticmethod + def _pop_otel_middleware(middlewares: list[Middleware]) -> tuple[list[Middleware], DefineMiddleware | None]: + """Get the OpenTelemetry middleware if it is enabled in the application. + Remove the middleware from the list of middlewares if it is found. + """ + otel_middleware: DefineMiddleware | None = None + other_middlewares = [] + for middleware in middlewares: + if ( + isinstance(middleware, DefineMiddleware) + and isinstance(middleware.middleware, type) + and issubclass(middleware.middleware, OpenTelemetryInstrumentationMiddleware) + ): + otel_middleware = middleware + else: + other_middlewares.append(middleware) + return other_middlewares, otel_middleware diff --git a/tests/unit/test_contrib/test_opentelemetry_deprecation.py b/tests/unit/test_contrib/test_opentelemetry_deprecation.py new file mode 100644 index 0000000000..aaef2ad4f3 --- /dev/null +++ b/tests/unit/test_contrib/test_opentelemetry_deprecation.py @@ -0,0 +1,95 @@ +"""Tests for litestar.contrib.opentelemetry deprecation.""" + +from __future__ import annotations + +import sys +import warnings + + +def test_contrib_opentelemetry_module_deprecation() -> None: + """Test that importing from litestar.contrib.opentelemetry emits deprecation warning.""" + # Remove module from cache to ensure fresh import + if "litestar.contrib.opentelemetry" in sys.modules: + del sys.modules["litestar.contrib.opentelemetry"] + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + import litestar.contrib.opentelemetry # noqa: F401 + + # Check if any deprecation warnings were emitted + deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] + assert len(deprecation_warnings) > 0, "Expected deprecation warning for module import" + assert any("litestar.contrib.opentelemetry" in str(warning.message) for warning in deprecation_warnings) + assert any("litestar.plugins.opentelemetry" in str(warning.message) for warning in deprecation_warnings) + + +def test_contrib_opentelemetry_config_deprecation() -> None: + """Test that importing OpenTelemetryConfig from contrib emits deprecation warning.""" + # Remove module from cache to ensure fresh import + if "litestar.contrib.opentelemetry" in sys.modules: + del sys.modules["litestar.contrib.opentelemetry"] + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from litestar.contrib.opentelemetry import OpenTelemetryConfig # noqa: F401 + + deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] + # Should have at least module-level warning (attribute warning might not fire if already imported) + assert len(deprecation_warnings) > 0, "Expected deprecation warning" + + +def test_contrib_opentelemetry_middleware_deprecation() -> None: + """Test that importing OpenTelemetryInstrumentationMiddleware from contrib emits deprecation warning.""" + # Remove module from cache to ensure fresh import + if "litestar.contrib.opentelemetry" in sys.modules: + del sys.modules["litestar.contrib.opentelemetry"] + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from litestar.contrib.opentelemetry import OpenTelemetryInstrumentationMiddleware # noqa: F401 + + deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] + assert len(deprecation_warnings) > 0, "Expected deprecation warning" + + +def test_contrib_opentelemetry_plugin_deprecation() -> None: + """Test that importing OpenTelemetryPlugin from contrib emits deprecation warning.""" + # Remove module from cache to ensure fresh import + if "litestar.contrib.opentelemetry" in sys.modules: + del sys.modules["litestar.contrib.opentelemetry"] + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from litestar.contrib.opentelemetry import OpenTelemetryPlugin # noqa: F401 + + deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] + assert len(deprecation_warnings) > 0, "Expected deprecation warning" + + +def test_plugins_opentelemetry_no_warning() -> None: + """Test that importing from litestar.plugins.opentelemetry does NOT emit warnings.""" + import warnings + + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + from litestar.plugins.opentelemetry import ( # noqa: F401 + OpenTelemetryConfig, + OpenTelemetryInstrumentationMiddleware, + OpenTelemetryPlugin, + ) + + # Filter out warnings that are not DeprecationWarning + deprecation_warnings = [w for w in warning_list if issubclass(w.category, DeprecationWarning)] + assert len(deprecation_warnings) == 0, ( + "No deprecation warnings should be emitted from litestar.plugins.opentelemetry" + ) + + +def test_functional_equivalence() -> None: + """Test that deprecated and new imports provide the same classes.""" + # Note: The module-level deprecation warning already fired in previous tests + # This test verifies functional equivalence only + from litestar.contrib.opentelemetry import OpenTelemetryPlugin as DeprecatedPlugin + from litestar.plugins.opentelemetry import OpenTelemetryPlugin as NewPlugin + + assert DeprecatedPlugin is NewPlugin, "Deprecated and new imports should provide the same class"