Skip to content

Commit 41a521d

Browse files
authored
Update deps, clarify public timing API, add docs (#12)
1 parent 16657de commit 41a521d

File tree

7 files changed

+398
-190
lines changed

7 files changed

+398
-190
lines changed

docs/release-notes.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
## Latest changes
22

3-
* Fix bug with ordering of routes in a CBV router
3+
## 0.2.0
4+
5+
* Make some of the functions/classes in `fastapi_utils.timing` private to clarify the intended public API
6+
* Add documentation for `fastapi_utils.timing` module
7+
* Fix bug with ordering of routes in a CBV router
48

59
## 0.1.1
610

docs/src/timing1.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import asyncio
2+
import logging
3+
4+
from fastapi import FastAPI
5+
from starlette.requests import Request
6+
from starlette.staticfiles import StaticFiles
7+
from starlette.testclient import TestClient
8+
9+
from fastapi_utils.timing import add_timing_middleware, record_timing
10+
11+
logging.basicConfig(level=logging.INFO)
12+
logger = logging.getLogger(__name__)
13+
14+
app = FastAPI()
15+
add_timing_middleware(app, record=logger.info, prefix="app", exclude="untimed")
16+
static_files_app = StaticFiles(directory=".")
17+
app.mount(path="/static", app=static_files_app, name="static")
18+
19+
20+
@app.get("/timed")
21+
async def get_timed() -> None:
22+
await asyncio.sleep(0.05)
23+
24+
25+
@app.get("/untimed")
26+
async def get_untimed() -> None:
27+
await asyncio.sleep(0.1)
28+
29+
30+
@app.get("/timed-intermediate")
31+
async def get_with_intermediate_timing(request: Request) -> None:
32+
await asyncio.sleep(0.1)
33+
record_timing(request, note="halfway")
34+
await asyncio.sleep(0.1)
35+
36+
37+
TestClient(app).get("/timed")
38+
# INFO:__main__:TIMING: Wall: 53.0ms
39+
# | CPU: 1.2ms
40+
# | app.__main__.get_timed
41+
42+
TestClient(app).get("/untimed")
43+
# <nothing logged>
44+
45+
TestClient(app).get("/timed-intermediate")
46+
# INFO:__main__:TIMING: Wall: 105.3ms
47+
# | CPU: 0.4ms
48+
# | app.__main__.get_with_intermediate_timing (halfway)
49+
# INFO:__main__:TIMING: Wall: 206.7ms
50+
# | CPU: 1.1ms
51+
# | app.__main__.get_with_intermediate_timing
52+
53+
TestClient(app).get("/static/test")
54+
# INFO:__main__:TIMING: Wall: 1.6ms
55+
# | CPU: 1.6ms
56+
# | StaticFiles<'static'>

docs/user-guide/timing-middleware.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,60 @@
22

33
---
44

5-
Documentation coming soon!
5+
The `fastapi_utils.timing` module provides basic profiling functionality that could be
6+
used to find performance bottlenecks, monitor for regressions, etc.
7+
8+
There are currently two public functions provided by this module:
9+
10+
* `add_timing_middleware`, which can be used to add a middleware to a `FastAPI` app that will
11+
log very basic profiling information for each request (with low overhead).
12+
13+
* `record_timing`, which can be called on a `starlette.requests.Request` instance for a `FastAPI`
14+
app with the timing middleware installed (via `add_timing_middleware`), and will emit performance
15+
information for the request at the point at which it is called.
16+
17+
!!! tip
18+
If you are look for more fine-grained performance profiling data, consider
19+
<a href="https://github.com/sumerc/yappi" target="_blank">`yappi`</a>,
20+
a python profiling library that was recently updated with coroutine support to enable
21+
better coroutine-aware profiling.
22+
23+
Note however that `yappi` adds considerable runtime overhead, and should typically be used during
24+
development rather than production.
25+
26+
The middleware provided in this package is intended to be sufficiently performant for production use.
27+
28+
29+
## Adding timing middleware
30+
31+
The `add_timing_middleware` function takes the following arguments:
32+
33+
* `app: FastAPI` : The app to which to add the timing middleware
34+
* `record: Optional[Callable[[str], None]] = None` : The callable to call on the generated timing messages.
35+
If not provided, defaults to `print`; a good choice is the `info` method of a `logging.Logger` instance
36+
* `prefix: str = ""` : A prefix to prepend to the generated route names. This can be useful for, e.g.,
37+
distinguishing between mounted ASGI apps.
38+
* `exclude: Optional[str] = None` : If provided, any route whose generated name includes this value will not have its
39+
timing stats recorded.
40+
41+
Here's an example demonstrating what the logged output looks like (note that the commented output has been
42+
split to multiple lines for ease of reading here, but each timing record is actually a single line):
43+
44+
```python hl_lines="15 37 42 45 53"
45+
{!./src/timing1.py!}
46+
```
47+
48+
## Recording intermediate timings
49+
50+
In the above example, you can see the `get_with_intermediate_timing` function used in
51+
the `/timed-intermediate` endpoint to record an intermediate execution duration:
52+
53+
```python hl_lines="33 46 47 48"
54+
{!./src/timing1.py!}
55+
```
56+
57+
Note that this requires the app that generated the `Request` instance to have had the timing middleware
58+
added using the `add_timing_middleware` function.
59+
60+
This can be used to output multiple records at distinct times in order to introspect the relative
61+
contributions of different execution steps in a single endpoint.

fastapi_utils/timing.py

Lines changed: 96 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,70 @@
1616
from starlette.requests import Request
1717
from starlette.responses import Response
1818
from starlette.routing import Match, Mount
19+
from starlette.types import Scope
1920

21+
TIMER_ATTRIBUTE = "__fastapi_utils_timer__"
22+
23+
24+
def add_timing_middleware(
25+
app: FastAPI, record: Optional[Callable[[str], None]] = None, prefix: str = "", exclude: Optional[str] = None
26+
) -> None:
27+
"""
28+
Adds a middleware to the provided `app` that records timing metrics using the provided `record` callable.
29+
30+
Typically `record` would be something like `logger.info` for a `logging.Logger` instance.
31+
32+
The provided `prefix` is used when generating route names.
33+
34+
If `exclude` is provided, timings for any routes containing `exclude`
35+
as an exact substring of the generated metric name will not be logged.
36+
This provides an easy way to disable logging for routes
37+
38+
The `exclude` will probably be replaced by a regex match at some point in the future. (PR welcome!)
39+
"""
40+
metric_namer = _MetricNamer(prefix=prefix, app=app)
41+
42+
@app.middleware("http")
43+
async def timing_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response:
44+
metric_name = metric_namer(request.scope)
45+
with _TimingStats(metric_name, record=record, exclude=exclude) as timer:
46+
setattr(request.state, TIMER_ATTRIBUTE, timer)
47+
response = await call_next(request)
48+
return response
49+
50+
51+
def record_timing(request: Request, note: Optional[str] = None) -> None:
52+
"""
53+
Call this function at any point that you want to display elapsed time during the handling of a single request
54+
55+
This can help profile which piece of a request is causing a performance bottleneck.
56+
57+
Note that for this function to succeed, the request should have been generated by a FastAPI app
58+
that has had timing middleware added using the `fastapi_utils.timing.add_timing_middleware` function.
59+
"""
60+
timer = getattr(request.state, TIMER_ATTRIBUTE, None)
61+
if timer is not None:
62+
assert isinstance(timer, _TimingStats)
63+
timer.emit(note)
64+
else:
65+
raise ValueError("No timer present on request")
66+
67+
68+
class _TimingStats:
69+
"""
70+
This class tracks and records endpoint timing data.
71+
72+
Should be used as a context manager; on exit, timing stats will be emitted.
73+
74+
name:
75+
The name to include with the recorded timing data
76+
record:
77+
The callable to call on generated messages. Defaults to `print`, but typically
78+
something like `logger.info` for a `logging.Logger` instance would be preferable.
79+
exclude:
80+
An optional string; if it is not None and occurs inside `name`, no stats will be emitted
81+
"""
2082

21-
class TimingStats:
2283
def __init__(
2384
self, name: Optional[str] = None, record: Callable[[str], None] = None, exclude: Optional[str] = None
2485
) -> None:
@@ -31,16 +92,16 @@ def __init__(
3192
self.end_time: float = 0
3293
self.silent: bool = False
3394

34-
if self.name and exclude and (exclude in self.name):
95+
if self.name is not None and exclude is not None and (exclude in self.name):
3596
self.silent = True
3697

3798
def start(self) -> None:
3899
self.start_time = time.time()
39-
self.start_cpu_time = get_cpu_time()
100+
self.start_cpu_time = _get_cpu_time()
40101

41102
def take_split(self) -> None:
42103
self.end_time = time.time()
43-
self.end_cpu_time = get_cpu_time()
104+
self.end_cpu_time = _get_cpu_time()
44105

45106
@property
46107
def time(self) -> float:
@@ -50,14 +111,17 @@ def time(self) -> float:
50111
def cpu_time(self) -> float:
51112
return self.end_cpu_time - self.start_cpu_time
52113

53-
def __enter__(self) -> "TimingStats":
114+
def __enter__(self) -> "_TimingStats":
54115
self.start()
55116
return self
56117

57118
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
58119
self.emit()
59120

60121
def emit(self, note: Optional[str] = None) -> None:
122+
"""
123+
Emit timing information, optionally including a specified note
124+
"""
61125
if not self.silent:
62126
self.take_split()
63127
cpu_ms = 1000 * self.cpu_time
@@ -68,14 +132,35 @@ def emit(self, note: Optional[str] = None) -> None:
68132
self.record(message)
69133

70134

71-
class MetricNamer:
135+
class _MetricNamer:
136+
"""
137+
This class generates the route "name" used when logging timing records.
138+
139+
If the route has `endpoint` and `name` attributes, the endpoint's module and route's name will be used
140+
(along with an optional prefix that can be used, e.g., to distinguish between multiple mounted ASGI apps).
141+
142+
By default, in FastAPI the route name is the `__name__` of the route's function (or type if it is a callable class
143+
instance).
144+
145+
For example, with prefix == "custom", a function defined in the module `app.crud` with name `read_item`
146+
would get name `custom.app.crud.read_item`. If the empty string were used as the prefix, the result would be
147+
just "app.crud.read_item".
148+
149+
For starlette.routing.Mount instances, the name of the type of `route.app` is used in a slightly different format.
150+
151+
For other routes missing either an endpoint or name, the raw route path is included in the generated name.
152+
"""
153+
72154
def __init__(self, prefix: str, app: FastAPI):
73155
if prefix:
74156
prefix += "."
75157
self.prefix = prefix
76158
self.app = app
77159

78-
def __call__(self, scope: Any) -> str:
160+
def __call__(self, scope: Scope) -> str:
161+
"""
162+
Generates the actual name to use when logging timing metrics for a specified ASGI Scope
163+
"""
79164
route = None
80165
for r in self.app.router.routes:
81166
if r.matches(scope)[0] == Match.FULL:
@@ -90,36 +175,10 @@ def __call__(self, scope: Any) -> str:
90175
return name
91176

92177

93-
def get_cpu_time() -> float:
94-
# taken from timing-asgi
178+
def _get_cpu_time() -> float:
179+
"""
180+
Generates the cpu time to report. Adds the user and system time, following the implementation from timing-asgi
181+
"""
95182
resources = resource.getrusage(resource.RUSAGE_SELF)
96183
# add up user time (ru_utime) and system time (ru_stime)
97184
return resources[0] + resources[1]
98-
99-
100-
def add_timing_middleware(
101-
app: FastAPI, record: Callable[[str], None] = None, prefix: str = "", exclude: Optional[str] = None
102-
) -> None:
103-
"""
104-
Don't print timings if exclude occurs as an exact substring of the generated metric name
105-
"""
106-
metric_namer = MetricNamer(prefix=prefix, app=app)
107-
108-
@app.middleware("http")
109-
async def timing_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response:
110-
metric_name = metric_namer(request.scope)
111-
with TimingStats(metric_name, record=record, exclude=exclude) as timer:
112-
request.state.timer = timer
113-
response = await call_next(request)
114-
return response
115-
116-
117-
def record_timing(request: Request, note: Optional[str] = None) -> None:
118-
"""
119-
Call this function anywhere you want to display performance information while handling a single request
120-
"""
121-
if hasattr(request.state, "timer"):
122-
assert isinstance(request.state.timer, TimingStats)
123-
request.state.timer.emit(note)
124-
else:
125-
print("TIMING ERROR: No timer present on request")

0 commit comments

Comments
 (0)