Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,9 @@ __pypackages__/
certs/
pdm.toml
.zed
.claude
.gemini
specs/
CLAUDE.md
AGENTS.md
GEMINI.md
4 changes: 4 additions & 0 deletions docs/reference/contrib/opentelemetry.rst
Original file line number Diff line number Diff line change
@@ -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 </reference/plugins/opentelemetry>` instead.

.. automodule:: litestar.contrib.opentelemetry
:members:

Expand Down
1 change: 1 addition & 0 deletions docs/reference/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ plugins
attrs
flash_messages
htmx
opentelemetry
problem_details
prometheus
pydantic
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/plugins/opentelemetry.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
opentelemetry
=============

.. automodule:: litestar.plugins.opentelemetry
:members:

.. autoclass:: litestar.plugins.opentelemetry.config.OpenTelemetryHookHandler
10 changes: 5 additions & 5 deletions docs/usage/metrics/open-telemetry.rst
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <litestar.contrib.opentelemetry.OpenTelemetryConfig>` and passing the middleware it creates to
of :class:`OpenTelemetryConfig <litestar.plugins.opentelemetry.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()

Expand All @@ -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 <https://opentelemetry.io/docs/instrumentation/python/exporters/>`_ for further details).

You can also pass con figuration to the ``OpenTelemetryConfig`` telling it which providers to use. Consult
:class:`reference docs <litestar.contrib.opentelemetry.OpenTelemetryConfig>` 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 <litestar.plugins.opentelemetry.OpenTelemetryConfig>` regarding the configuration options you can use.
43 changes: 40 additions & 3 deletions litestar/contrib/opentelemetry/__init__.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a deprecation that's scheduled for a removal, this should be pointed at the v2 branch :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some followup changes for this in the main branch that i was making in a followup PR. Can I open another PR once this merges to get this into the v2 branch?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you could merged these into main and then cherry-pick them into v2

Original file line number Diff line number Diff line change
@@ -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,
)
9 changes: 9 additions & 0 deletions litestar/plugins/opentelemetry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .config import OpenTelemetryConfig
from .middleware import OpenTelemetryInstrumentationMiddleware
from .plugin import OpenTelemetryPlugin

__all__ = (
"OpenTelemetryConfig",
"OpenTelemetryInstrumentationMiddleware",
"OpenTelemetryPlugin",
)
37 changes: 37 additions & 0 deletions litestar/plugins/opentelemetry/_utils.py
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions litestar/plugins/opentelemetry/config.py
Original file line number Diff line number Diff line change
@@ -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 <litestar.middleware.base.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)
58 changes: 58 additions & 0 deletions litestar/plugins/opentelemetry/middleware.py
Original file line number Diff line number Diff line change
@@ -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]
51 changes: 51 additions & 0 deletions litestar/plugins/opentelemetry/plugin.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading