Skip to content

Commit 7d928f9

Browse files
committed
Add litestar integrations, refactor project
1 parent 33bf56d commit 7d928f9

File tree

17 files changed

+721
-84
lines changed

17 files changed

+721
-84
lines changed

examples/litestar_app.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import asyncio
2+
import logging
3+
from datetime import datetime, timezone
4+
5+
import uvicorn
6+
from litestar import Litestar, get
7+
from opentelemetry import trace
8+
from opentelemetry.sdk.resources import Resource
9+
from opentelemetry.sdk.trace import TracerProvider
10+
11+
from asgi_monitor.integrations.litestar import (
12+
MetricsConfig,
13+
TracingConfig,
14+
add_metrics_endpoint,
15+
build_metrics_middleware,
16+
build_tracing_middleware,
17+
)
18+
from asgi_monitor.logging import configure_logging
19+
from asgi_monitor.logging.uvicorn import build_uvicorn_log_config
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
@get("/")
25+
async def index() -> str:
26+
logger.info("Start sleeping at %s", datetime.now(tz=timezone.utc))
27+
await asyncio.sleep(1)
28+
logger.info("Stopped sleeping at %s", datetime.now(tz=timezone.utc))
29+
return "OK"
30+
31+
32+
def create_app() -> Litestar:
33+
configure_logging(level=logging.INFO, json_format=True, include_trace=False)
34+
35+
resource = Resource.create(
36+
attributes={
37+
"service.name": "litestar",
38+
},
39+
)
40+
tracer = TracerProvider(resource=resource)
41+
trace.set_tracer_provider(tracer)
42+
43+
trace_config = TracingConfig(tracer_provider=tracer)
44+
metrics_config = MetricsConfig(app_name="litestar", include_trace_exemplar=True)
45+
middlewares = [build_tracing_middleware(trace_config), build_metrics_middleware(metrics_config)]
46+
47+
app = Litestar([index], middleware=middlewares, logging_config=None)
48+
49+
add_metrics_endpoint(app, metrics_config.registry)
50+
51+
return app
52+
53+
54+
if __name__ == "__main__":
55+
log_config = build_uvicorn_log_config(
56+
level=logging.INFO,
57+
json_format=True,
58+
include_trace=True,
59+
)
60+
uvicorn.run(create_app(), host="127.0.0.1", port=8000, log_config=log_config)

pdm.lock

Lines changed: 254 additions & 47 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ asgi = [
5959
"uvicorn>=0.27.1",
6060
"fastapi>=0.110.0",
6161
"starlette>=0.36.3",
62+
"litestar>=2.7.1",
6263
]
6364
wsgi = [
6465
"gunicorn>=21.2.0",

src/asgi_monitor/integrations/fastapi.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
44
from typing import TYPE_CHECKING, Any, Callable
55

6+
from asgi_monitor.metrics.manager import build_metrics_manager
7+
68
if TYPE_CHECKING:
79
from fastapi import FastAPI
810

@@ -13,7 +15,6 @@
1315
get_metrics,
1416
)
1517
from asgi_monitor.metrics.config import BaseMetricsConfig
16-
from asgi_monitor.metrics.container import MetricsContainer
1718
from asgi_monitor.tracing import BaseTracingConfig
1819

1920
__all__ = (
@@ -33,6 +34,9 @@ class MetricsConfig(BaseMetricsConfig):
3334
metrics_prefix: str = "fastapi"
3435
"""The prefix to use for the metrics."""
3536

37+
include_metrics_endpoint: bool = field(default=True)
38+
"""Whether to include a /metrics endpoint."""
39+
3640

3741
@dataclass(slots=True, frozen=True)
3842
class TracingConfig(BaseTracingConfig):
@@ -61,7 +65,7 @@ def setup_tracing(app: FastAPI, config: TracingConfig) -> None:
6165
The function adds a TracingMiddleware to the FastAPI application based on TracingConfig.
6266
6367
:param FastAPI app: The FastAPI application instance.
64-
:param TracingConfig config: The Open Telemetry config.
68+
:param TracingConfig config: The OpenTelemetry config.
6569
:returns: None
6670
"""
6771

@@ -73,19 +77,21 @@ def setup_metrics(app: FastAPI, config: MetricsConfig) -> None:
7377
Set up metrics for a FastAPI application.
7478
This function adds a MetricsMiddleware to the FastAPI application with the specified parameters.
7579
76-
:param FastAPI app: The Starlette application instance.
80+
:param FastAPI app: The FastAPI application instance.
7781
:param MetricsConfig config: Configuration for the metrics.
7882
:returns: None
7983
"""
8084

81-
app.state.metrics_registry = config.registry
85+
metrics = build_metrics_manager(config)
86+
metrics.add_app_info()
87+
8288
app.add_middleware(
8389
MetricsMiddleware,
84-
app_name=config.app_name,
85-
container=MetricsContainer(config.metrics_prefix, config.registry),
90+
metrics=metrics,
8691
include_trace_exemplar=config.include_trace_exemplar,
8792
)
8893
if config.include_metrics_endpoint:
94+
app.state.metrics_registry = config.registry
8995
app.add_route(
9096
path="/metrics",
9197
route=get_metrics,
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
from __future__ import annotations
2+
3+
import time
4+
from dataclasses import dataclass
5+
from functools import wraps
6+
from typing import TYPE_CHECKING, Any, Callable
7+
8+
from litestar import Request, Response, get
9+
from litestar.enums import ScopeType
10+
from litestar.middleware.base import AbstractMiddleware, DefineMiddleware
11+
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
12+
from opentelemetry import trace
13+
from opentelemetry.semconv.trace import SpanAttributes
14+
15+
if TYPE_CHECKING:
16+
from litestar import Litestar
17+
from litestar.types import ASGIApp, Message, Receive, Scope, Send
18+
from prometheus_client import CollectorRegistry
19+
20+
from asgi_monitor.metrics import get_latest_metrics
21+
from asgi_monitor.metrics.config import BaseMetricsConfig
22+
from asgi_monitor.metrics.manager import MetricsManager, build_metrics_manager
23+
from asgi_monitor.tracing.config import BaseTracingConfig
24+
from asgi_monitor.tracing.middleware import build_open_telemetry_middleware
25+
26+
__all__ = (
27+
"TracingConfig",
28+
"build_tracing_middleware",
29+
"MetricsConfig",
30+
"build_metrics_middleware",
31+
"add_metrics_endpoint",
32+
)
33+
34+
35+
def _get_default_span_details(scope: Scope) -> tuple[str, dict[str, Any]]:
36+
route_handler_fn_name = scope["route_handler"].handler_name
37+
return route_handler_fn_name, {SpanAttributes.HTTP_ROUTE: route_handler_fn_name}
38+
39+
40+
@dataclass(slots=True, frozen=True)
41+
class TracingConfig(BaseTracingConfig):
42+
"""
43+
Configuration class for the OpenTelemetry middleware.
44+
Consult the OpenTelemetry ASGI documentation for more info about the configuration options.
45+
https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html
46+
"""
47+
48+
exclude_urls_env_key: str = "LITESTAR"
49+
"""
50+
Key to use when checking whether a list of excluded urls is passed via ENV.
51+
OpenTelemetry supports excluding urls by passing an env in the format '{exclude_urls_env_key}_EXCLUDED_URLS'.
52+
"""
53+
54+
scope_span_details_extractor: Callable[[Any], tuple[str, dict[str, Any]]] = _get_default_span_details
55+
"""
56+
Callback which should return a string and a tuple, representing the desired default span name and a dictionary
57+
with any additional span attributes to set.
58+
"""
59+
60+
61+
@dataclass(slots=True, frozen=True)
62+
class MetricsConfig(BaseMetricsConfig):
63+
"""Configuration class for the Metrics middleware."""
64+
65+
metrics_prefix: str = "litestar"
66+
"""The prefix to use for the metrics."""
67+
68+
69+
class TracingMiddleware(AbstractMiddleware):
70+
__slots__ = ("app", "open_telemetry_middleware")
71+
72+
def __init__(self, app: ASGIApp, config: TracingConfig) -> None:
73+
super().__init__(app, scopes={ScopeType.HTTP})
74+
self.open_telemetry_middleware = build_open_telemetry_middleware(app, config)
75+
76+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
77+
return await self.open_telemetry_middleware(scope, receive, send) # type: ignore[no-any-return]
78+
79+
80+
def _get_wrapped_send(send: Send, request_span: dict[str, float]) -> Callable:
81+
@wraps(send)
82+
async def wrapped_send(message: Message) -> None:
83+
if message["type"] == "http.response.start":
84+
request_span["status_code"] = message["status"]
85+
86+
if message["type"] == "http.response.body":
87+
request_span["duration"] = time.perf_counter() - request_span["start_time"]
88+
await send(message)
89+
90+
return wrapped_send
91+
92+
93+
class MetricsMiddleware(AbstractMiddleware):
94+
def __init__(
95+
self,
96+
app: ASGIApp,
97+
metrics: MetricsManager,
98+
*,
99+
include_trace_exemplar: bool,
100+
) -> None:
101+
super().__init__(app, scopes={ScopeType.HTTP})
102+
self.metrics = metrics
103+
self.include_exemplar = include_trace_exemplar
104+
105+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
106+
request = Request[Any, Any, Any](scope, receive)
107+
108+
method = request.method
109+
path = request.url.path
110+
111+
self.metrics.inc_requests_count(method=method, path=path)
112+
self.metrics.add_request_in_progress(method=method, path=path)
113+
114+
request_span = {
115+
"start_time": time.perf_counter(),
116+
"duration": 0,
117+
"status_code": HTTP_500_INTERNAL_SERVER_ERROR,
118+
}
119+
120+
wrapped_send = _get_wrapped_send(send, request_span)
121+
122+
try:
123+
await self.app(scope, receive, wrapped_send)
124+
finally:
125+
if request_span["status_code"] >= HTTP_500_INTERNAL_SERVER_ERROR:
126+
self.metrics.inc_requests_exceptions_count(
127+
method=method,
128+
path=path,
129+
exception_type="UNSET",
130+
)
131+
132+
exemplar: dict[str, str] | None = None
133+
134+
if self.include_exemplar:
135+
span = trace.get_current_span()
136+
trace_id = trace.format_trace_id(span.get_span_context().trace_id)
137+
exemplar = {"TraceID": trace_id}
138+
139+
self.metrics.observe_request_duration(
140+
method=method,
141+
path=path,
142+
duration=request_span["duration"],
143+
exemplar=exemplar,
144+
)
145+
146+
self.metrics.inc_responses_count(method=method, path=path, status_code=request_span["status_code"])
147+
self.metrics.remove_request_in_progress(method=method, path=path)
148+
149+
150+
@get(path="/metrics", summary="Get Prometheus metrics", include_in_schema=True)
151+
async def get_metrics(request: Request) -> Response:
152+
registry = request.app.state.metrics_registry
153+
response = get_latest_metrics(registry, openmetrics_format=False)
154+
return Response(
155+
content=response.payload,
156+
status_code=response.status_code,
157+
headers=response.headers,
158+
)
159+
160+
161+
def build_tracing_middleware(config: TracingConfig) -> DefineMiddleware:
162+
"""
163+
Build TracingMiddleware for a Litestar application.
164+
The function adds a TracingMiddleware to the Litestar application based on TracingConfig.
165+
166+
:param TracingConfig config: The OpenTelemetry config.
167+
:returns: None
168+
"""
169+
170+
return DefineMiddleware(
171+
TracingMiddleware,
172+
config=config,
173+
)
174+
175+
176+
def build_metrics_middleware(config: MetricsConfig) -> DefineMiddleware:
177+
"""
178+
Build MetricsMiddleware for a Litestar application.
179+
180+
:param MetricsConfig config: Configuration for the metrics.
181+
:returns: DefineMiddleware
182+
"""
183+
184+
metrics = build_metrics_manager(config)
185+
metrics.add_app_info()
186+
187+
return DefineMiddleware(
188+
MetricsMiddleware,
189+
metrics=metrics,
190+
include_trace_exemplar=config.include_trace_exemplar,
191+
)
192+
193+
194+
def add_metrics_endpoint(app: Litestar, registry: CollectorRegistry) -> None:
195+
"""
196+
Add CollectorRegistry in state and register /metrics endpoint.
197+
198+
:param Litestar app: The Litestar application instance.
199+
:param CollectorRegistry registry: The registry for the metrics.
200+
:returns: None
201+
"""
202+
203+
app.state.metrics_registry = registry
204+
app.register(get_metrics)

0 commit comments

Comments
 (0)