Skip to content

Commit 05e7dc5

Browse files
feat: implement FastAPI metric interceptor for Prometheus with path template caching
1 parent 1a30962 commit 05e7dc5

File tree

8 files changed

+726
-157
lines changed

8 files changed

+726
-157
lines changed

.env.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ STARROCKS_SQLALCHEMY__USERNAME=root
4949
STARROCKS_SQLALCHEMY__PASSWORD=
5050

5151
# Keycloak Configuration
52-
KEYCLOAK__IMAGE=quay.io/keycloak/keycloak:26.5.3
52+
KEYCLOAK__IMAGE=quay.io/keycloak/keycloak:26.5.4
5353
KEYCLOAK__SERVER_URL=http://localhost:8080
5454
KEYCLOAK__ADMIN_USERNAME=admin
5555
KEYCLOAK__ADMIN_PASSWORD=admin
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from archipy.helpers.interceptors.fastapi.metric.interceptor import FastAPIMetricInterceptor
2+
from archipy.helpers.interceptors.fastapi.rate_limit.fastapi_rest_rate_limit_handler import (
3+
FastAPIRestRateLimitHandler,
4+
)
5+
6+
__all__ = ["FastAPIMetricInterceptor", "FastAPIRestRateLimitHandler"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from archipy.helpers.interceptors.fastapi.metric.interceptor import FastAPIMetricInterceptor
2+
3+
__all__ = ["FastAPIMetricInterceptor"]
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from __future__ import annotations
2+
3+
import time
4+
from collections.abc import Awaitable, Callable
5+
from typing import TYPE_CHECKING, ClassVar
6+
7+
from prometheus_client import Gauge, Histogram
8+
from starlette.middleware.base import BaseHTTPMiddleware
9+
from starlette.routing import Match
10+
11+
from archipy.configs.base_config import BaseConfig
12+
from archipy.helpers.utils.base_utils import BaseUtils
13+
14+
if TYPE_CHECKING:
15+
from fastapi import Request, Response
16+
from starlette.types import ASGIApp
17+
18+
19+
class FastAPIMetricInterceptor(BaseHTTPMiddleware):
20+
"""A FastAPI interceptor for collecting and reporting metrics using Prometheus.
21+
22+
This interceptor measures the response time of HTTP requests and records it in a Prometheus histogram.
23+
It also tracks the number of active requests using a Prometheus gauge.
24+
The interceptor captures errors and logs them for monitoring purposes.
25+
"""
26+
27+
ZERO_TO_ONE_SECONDS_BUCKETS: ClassVar[list[float]] = [i / 1000 for i in range(0, 1000, 5)]
28+
ONE_TO_FIVE_SECONDS_BUCKETS: ClassVar[list[float]] = [i / 100 for i in range(100, 500, 20)]
29+
FIVE_TO_THIRTY_SECONDS_BUCKETS: ClassVar[list[float]] = [i / 100 for i in range(500, 3000, 50)]
30+
TOTAL_BUCKETS: ClassVar[list[float]] = (
31+
ZERO_TO_ONE_SECONDS_BUCKETS + ONE_TO_FIVE_SECONDS_BUCKETS + FIVE_TO_THIRTY_SECONDS_BUCKETS + [float("inf")]
32+
)
33+
34+
RESPONSE_TIME_SECONDS: ClassVar[Histogram] = Histogram(
35+
"fastapi_response_time_seconds",
36+
"Time spent processing HTTP request",
37+
labelnames=("method", "status_code", "path_template"),
38+
buckets=TOTAL_BUCKETS,
39+
)
40+
41+
ACTIVE_REQUESTS: ClassVar[Gauge] = Gauge(
42+
"fastapi_active_requests",
43+
"Number of active HTTP requests",
44+
labelnames=("method", "path_template"),
45+
)
46+
47+
_path_template_cache: ClassVar[dict[str, str]] = {}
48+
49+
def __init__(self, app: ASGIApp) -> None:
50+
"""Initialize the FastAPI metric interceptor.
51+
52+
Args:
53+
app (ASGIApp): The ASGI application to wrap.
54+
"""
55+
super().__init__(app)
56+
57+
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
58+
"""Intercept HTTP requests to measure response time and track active requests.
59+
60+
Args:
61+
request (Request): The incoming HTTP request.
62+
call_next (Callable[[Request], Awaitable[Response]]): The next interceptor or endpoint to call.
63+
64+
Returns:
65+
Response: The HTTP response from the endpoint.
66+
67+
Raises:
68+
Exception: If an exception occurs during request processing, it is captured and re-raised.
69+
"""
70+
if not BaseConfig.global_config().PROMETHEUS.IS_ENABLED:
71+
return await call_next(request)
72+
73+
path_template = self._get_path_template(request)
74+
method = request.method
75+
76+
self.ACTIVE_REQUESTS.labels(method=method, path_template=path_template).inc()
77+
78+
start_time = time.time()
79+
status_code = 500
80+
81+
try:
82+
response = await call_next(request)
83+
status_code = response.status_code
84+
except Exception as exception:
85+
BaseUtils.capture_exception(exception)
86+
raise
87+
else:
88+
return response
89+
finally:
90+
duration = time.time() - start_time
91+
self.RESPONSE_TIME_SECONDS.labels(
92+
method=method,
93+
status_code=status_code,
94+
path_template=path_template,
95+
).observe(duration)
96+
self.ACTIVE_REQUESTS.labels(method=method, path_template=path_template).dec()
97+
98+
def _get_path_template(self, request: Request) -> str:
99+
"""Extract path template from request by matching against app routes with in-memory caching.
100+
101+
Args:
102+
request (Request): The FastAPI request object.
103+
104+
Returns:
105+
str: Path template (e.g., /users/{id}) or raw path if no route found.
106+
"""
107+
path = request.url.path
108+
method = request.method
109+
cache_key = f"{method}:{path}"
110+
111+
if cache_key in self._path_template_cache:
112+
return self._path_template_cache[cache_key]
113+
114+
for route in request.app.routes:
115+
match, _ = route.matches(request.scope)
116+
if match == Match.FULL:
117+
path_template = route.path
118+
self._path_template_cache[cache_key] = path_template
119+
return path_template
120+
121+
self._path_template_cache[cache_key] = path
122+
return path

archipy/helpers/utils/app_utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,36 @@ def setup_elastic_apm(app: FastAPI, config: BaseConfig) -> None:
197197
except Exception:
198198
logger.exception("Failed to initialize Elastic APM")
199199

200+
@staticmethod
201+
def setup_metric_interceptor(app: FastAPI, config: BaseConfig) -> None:
202+
"""Configures metric interceptor for FastAPI if Prometheus is enabled.
203+
204+
Args:
205+
app (FastAPI): The FastAPI application instance.
206+
config (BaseConfig): The configuration object containing Prometheus settings.
207+
"""
208+
if not config.PROMETHEUS.IS_ENABLED:
209+
return
210+
211+
try:
212+
import socket
213+
214+
from prometheus_client import start_http_server
215+
216+
from archipy.helpers.interceptors.fastapi.metric.interceptor import FastAPIMetricInterceptor
217+
218+
# Conditionally start Prometheus server (check if already running)
219+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
220+
result = sock.connect_ex(("localhost", config.PROMETHEUS.SERVER_PORT))
221+
sock.close()
222+
223+
if result != 0:
224+
start_http_server(config.PROMETHEUS.SERVER_PORT)
225+
226+
app.add_middleware(FastAPIMetricInterceptor) # type: ignore[arg-type]
227+
except Exception:
228+
logger.exception("Failed to initialize Metric Interceptor")
229+
200230
@staticmethod
201231
def setup_exception_handlers(app: FastAPI) -> None:
202232
"""Configures exception handlers for the FastAPI application.
@@ -382,6 +412,7 @@ def create_fastapi_app(
382412
FastAPIUtils.setup_sentry(config)
383413
FastAPIUtils.setup_cors(app, config)
384414
FastAPIUtils.setup_elastic_apm(app, config)
415+
FastAPIUtils.setup_metric_interceptor(app, config)
385416

386417
if configure_exception_handlers:
387418
FastAPIUtils.setup_exception_handlers(app)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
Feature: FastAPI Metric Interceptor
2+
3+
Scenario: Interceptor is added when Prometheus is enabled
4+
Given a FastAPI app with Prometheus enabled
5+
When the metric interceptor is setup
6+
Then the FastAPI app should have the metric interceptor
7+
8+
Scenario: Interceptor is skipped when Prometheus is disabled
9+
Given a FastAPI app with Prometheus disabled
10+
When the metric interceptor is setup
11+
Then the FastAPI app should not have the metric interceptor
12+
13+
Scenario: Response time is recorded for successful requests
14+
Given a FastAPI app with Prometheus enabled and metric interceptor
15+
When a GET request is made to "/test" endpoint
16+
Then the response time metric should be recorded
17+
And the metric should have method label "GET"
18+
And the metric should have status_code label "200"
19+
And the metric should have path_template label "/test"
20+
21+
Scenario: Response time is recorded for failed requests
22+
Given a FastAPI app with Prometheus enabled and metric interceptor
23+
When a GET request is made to an endpoint that raises an error
24+
Then the response time metric should be recorded with status code 500
25+
26+
Scenario: Active requests gauge increments and decrements correctly
27+
Given a FastAPI app with Prometheus enabled and metric interceptor
28+
When a GET request is made to "/test" endpoint
29+
Then the active requests gauge should increment before processing
30+
And the active requests gauge should decrement after processing
31+
32+
Scenario: Prometheus server starts only once
33+
Given Prometheus is enabled
34+
When multiple FastAPI apps are created
35+
Then the Prometheus server should only start once
36+
37+
Scenario Outline: Metrics include correct labels for parameterized routes
38+
Given a FastAPI app with Prometheus enabled and metric interceptor with routes
39+
When a <method> request is made to "<actual_path>" with route pattern "<route_pattern>"
40+
Then the metric should have path_template label "<route_pattern>"
41+
And the metric should not have path_template label "<actual_path>"
42+
And the metric should have method label "<method>"
43+
44+
Examples:
45+
| method | actual_path | route_pattern |
46+
| GET | /users/123 | /users/{id} |
47+
| POST | /users/456/posts | /users/{user_id}/posts |
48+
| GET | /api/v1/items/789 | /api/v1/items/{item_id} |
49+
| PUT | /orders/abc/items/xyz | /orders/{order_id}/items/{item_id} |
50+
| DELETE | /resources/test-123 | /resources/{resource_id} |
51+
52+
Scenario: Path template extraction works for routes without parameters
53+
Given a FastAPI app with Prometheus enabled and metric interceptor
54+
When a GET request is made to "/health" endpoint
55+
Then the metric should have path_template label "/health"
56+
57+
Scenario: Path template cache improves performance
58+
Given a FastAPI app with Prometheus enabled and metric interceptor with routes
59+
When multiple GET requests are made to "/users/123"
60+
Then all metrics should have the same path_template label "/users/{id}"
61+
And the cache should have stored the path template

0 commit comments

Comments
 (0)