Skip to content

Commit

Permalink
fix: OpenTelemetry doesn't capture exceptions in the outermost applic…
Browse files Browse the repository at this point in the history
…ation layer (#3689)

* fix(opentelemetry): wrap all middleware under Otel middleware to ensure spans are created and exceptions are logged correctly under that span

---------

Co-authored-by: bella <[email protected]>
  • Loading branch information
provinzkraut and bella authored Aug 24, 2024
1 parent 30e14a6 commit d1ef753
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 39 deletions.
4 changes: 2 additions & 2 deletions docs/usage/metrics/open-telemetry.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ the Litestar constructor:
.. code-block:: python
from litestar import Litestar
from litestar.contrib.opentelemetry import OpenTelemetryConfig
from litestar.contrib.opentelemetry import OpenTelemetryConfig, OpenTelemetryPlugin
open_telemetry_config = OpenTelemetryConfig()
app = Litestar(middleware=[open_telemetry_config.middleware])
app = Litestar(plugins=[OpenTelemetryPlugin(open_telemetry_config)])
The above example will work out of the box if you configure a global ``tracer_provider`` and/or ``metric_provider`` and an
exporter to use these (see the
Expand Down
29 changes: 27 additions & 2 deletions litestar/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from litestar.config.compression import CompressionConfig
from litestar.config.cors import CORSConfig
from litestar.config.csrf import CSRFConfig
from litestar.contrib.opentelemetry import OpenTelemetryPlugin
from litestar.datastructures import CacheControlHeader, ETag
from litestar.dto import AbstractDTO
from litestar.events.listener import EventListener
Expand Down Expand Up @@ -385,8 +386,10 @@ def __init__(
for handler in chain(
on_app_init or [],
(p.on_app_init for p in config.plugins if isinstance(p, InitPluginProtocol)),
[self._patch_opentelemetry_middleware],
):
config = handler(config) # pyright: ignore

self.plugins = PluginRegistry(config.plugins)

self._openapi_schema: OpenAPI | None = None
Expand Down Expand Up @@ -446,7 +449,6 @@ def __init__(
config.exception_handlers.setdefault(StarletteHTTPException, _starlette_exception_handler)
except ImportError:
pass

super().__init__(
after_request=config.after_request,
after_response=config.after_response,
Expand Down Expand Up @@ -492,6 +494,23 @@ def __init__(

self.asgi_handler = self._create_asgi_handler()

@staticmethod
def _patch_opentelemetry_middleware(config: AppConfig) -> AppConfig:
# workaround to support otel middleware priority. Should be replaced by regular
# middleware priorities once available
try:
from litestar.contrib.opentelemetry import OpenTelemetryPlugin

if not any(isinstance(p, OpenTelemetryPlugin) for p in config.plugins):
config.middleware, otel_middleware = OpenTelemetryPlugin._pop_otel_middleware(config.middleware)
if otel_middleware:
otel_plugin = OpenTelemetryPlugin()
otel_plugin._middleware = otel_middleware
config.plugins = [*config.plugins, otel_plugin]
except ImportError:
pass
return config

@property
@deprecated(version="2.6.0", kind="property", info="Use create_static_files router instead")
def static_files_config(self) -> list[StaticFilesConfig]:
Expand Down Expand Up @@ -843,7 +862,13 @@ def _create_asgi_handler(self) -> ASGIApp:
asgi_handler = wrap_in_exception_handler(app=self.asgi_router)

if self.cors_config:
return CORSMiddleware(app=asgi_handler, config=self.cors_config)
asgi_handler = CORSMiddleware(app=asgi_handler, config=self.cors_config)

try:
otel_plugin: OpenTelemetryPlugin = self.plugins.get("OpenTelemetryPlugin")
asgi_handler = otel_plugin.middleware(app=asgi_handler)
except KeyError:
pass

return asgi_handler

Expand Down
7 changes: 6 additions & 1 deletion litestar/contrib/opentelemetry/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from .config import OpenTelemetryConfig
from .middleware import OpenTelemetryInstrumentationMiddleware
from .plugin import OpenTelemetryPlugin

__all__ = ("OpenTelemetryConfig", "OpenTelemetryInstrumentationMiddleware")
__all__ = (
"OpenTelemetryConfig",
"OpenTelemetryInstrumentationMiddleware",
"OpenTelemetryPlugin",
)
10 changes: 8 additions & 2 deletions litestar/contrib/opentelemetry/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,11 @@ def get_route_details_from_scope(scope: Scope) -> tuple[str, dict[Any, str]]:
Returns:
A tuple of the span name and a dict of attrs.
"""
route_handler_fn_name = scope["route_handler"].handler_name
return route_handler_fn_name, {SpanAttributes.HTTP_ROUTE: route_handler_fn_name}

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
50 changes: 50 additions & 0 deletions litestar/contrib/opentelemetry/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from litestar.contrib.opentelemetry.config import OpenTelemetryConfig
from litestar.contrib.opentelemetry.middleware import OpenTelemetryInstrumentationMiddleware
from litestar.middleware.base import DefineMiddleware
from litestar.plugins import InitPluginProtocol

if TYPE_CHECKING:
from litestar.config.app import AppConfig
from litestar.types.composite_types import Middleware


class OpenTelemetryPlugin(InitPluginProtocol):
"""OpenTelemetry Plugin."""

__slots__ = ("config", "_middleware")

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 middleware.middleware is OpenTelemetryInstrumentationMiddleware
):
otel_middleware = middleware
else:
other_middlewares.append(middleware)
return other_middlewares, otel_middleware
Loading

0 comments on commit d1ef753

Please sign in to comment.