Skip to content

Commit 7aa3372

Browse files
author
Conor Heine
committed
Timing middleware: add support for alternate message formatters
1 parent af95ff4 commit 7aa3372

File tree

7 files changed

+181
-35
lines changed

7 files changed

+181
-35
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ This helps making sure that:
100100
During local development, there is a script that builds the site and checks for any changes, live-reloading:
101101

102102
```console
103-
$ bash scripts/docs-live.sh
103+
$ make docs-live
104104
```
105105

106106
It will serve the documentation on `http://0.0.0.0:8008`.

docs/contributing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ This helps making sure that:
100100
During local development, there is a script that builds the site and checks for any changes, live-reloading:
101101

102102
```console
103-
$ bash scripts/docs-live.sh
103+
$ make docs-live
104104
```
105105

106106
It will serve the documentation on `http://0.0.0.0:8008`.

docs/src/timing2.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 import timing
10+
11+
logging.basicConfig(level=logging.INFO)
12+
logger = logging.getLogger(__name__)
13+
14+
app = FastAPI()
15+
timing.add_timing_middleware(
16+
app,
17+
record=logger.info,
18+
prefix="app",
19+
exclude="untimed",
20+
format_message=timing.json_formatter,
21+
)
22+
static_files_app = StaticFiles(directory=".")
23+
app.mount(path="/static", app=static_files_app, name="static")
24+
25+
26+
@app.get("/json/timed")
27+
async def get_json_timed() -> None:
28+
await asyncio.sleep(0.05)
29+
30+
31+
@app.get("/json/untimed")
32+
async def get_json_untimed() -> None:
33+
await asyncio.sleep(0.1)
34+
35+
36+
@app.get("/json/timed-intermediate")
37+
async def get_json_with_intermediate_timing(request: Request) -> None:
38+
await asyncio.sleep(0.1)
39+
timing.record_timing(request, note="halfway")
40+
await asyncio.sleep(0.1)
41+
42+
43+
TestClient(app).get("/json/timed")
44+
# INFO:__main__:TIMING: {"wall_ms":53.0,"cpu_ms":1.2,"name":"app.__main__.get_json_timed","note":null}
45+
46+
TestClient(app).get("/json/untimed")
47+
# <nothing logged>
48+
49+
TestClient(app).get("/json/timed-intermediate")
50+
# INFO:__main__:TIMING: {"wall_ms":105.3,"cpu_ms":0.4,"name":"app.__main__.get_json_with_intermediate_timing","note":"halfway"}
51+
# INFO:__main__:TIMING: {"wall_ms":206.7,"cpu_ms":1.1,"name":"app.__main__.get_json_timed","note":null}
52+
53+
TestClient(app).get("/static/test")
54+
# INFO:__main__:TIMING: {"wall_ms":1.6,"cpu_ms":1.6,"name":"StaticFiles<'static'>","note":null}
Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#### Source module: [`fastapi_utils.timing`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/timing.py){.internal-link target=_blank}
1+
#### Source module: [`fastapi_utils.timing`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/timing.py){.internal-link target=\_blank}
22

33
---
44

@@ -7,39 +7,39 @@ used to find performance bottlenecks, monitor for regressions, etc.
77

88
There are currently two public functions provided by this module:
99

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).
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).
1212

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.
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.
1616

1717
!!! 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-
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+
2323
Note however that `yappi` adds considerable runtime overhead, and should typically be used during
2424
development rather than production.
25-
25+
2626
The middleware provided in this package is intended to be sufficiently performant for production use.
27-
2827

2928
## Adding timing middleware
3029

3130
The `add_timing_middleware` function takes the following arguments:
3231

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-
32+
- `app: FastAPI` : The app to which to add the timing middleware
33+
- `record: Optional[Callable[[str], None]] = None` : The callable to call on the generated timing messages.
34+
If not provided, defaults to `print`; a good choice is the `info` method of a `logging.Logger` instance
35+
- `prefix: str = ""` : A prefix to prepend to the generated route names. This can be useful for, e.g.,
36+
distinguishing between mounted ASGI apps.
37+
- `exclude: Optional[str] = None` : If provided, any route whose generated name includes this value will not have its
38+
timing stats recorded.
39+
- `format_message: Optional[Callable[[TimingInfo], Any]] = None` : Override the default message formatter. For example, to output structured JSON logs.
40+
4141
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):
42+
split to multiple lines for ease of reading here, but each timing record is actually a single line):
4343

4444
```python hl_lines="15 37 42 45 53"
4545
{!./src/timing1.py!}
@@ -58,4 +58,12 @@ Note that this requires the app that generated the `Request` instance to have ha
5858
added using the `add_timing_middleware` function.
5959

6060
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.
61+
contributions of different execution steps in a single endpoint.
62+
63+
## Custom message formatter
64+
65+
It may be useful to provide an alternate format for the timing output. For example, some log management solutions prefer ingesting structured logs. A `json_formatter` function is included, but any custom formatter may be provided:
66+
67+
```python hl_lines="9 15 37 40 43 47"
68+
{!./src/timing2.py!}
69+
```

fastapi_utils/timing.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
For more detailed performance investigations (during development only, due to added overhead),
88
consider using the coroutine-aware profiling library `yappi`.
99
"""
10+
import json
1011
import resource
1112
import time
13+
from dataclasses import asdict, dataclass
1214
from typing import Any, Callable, Optional
1315

1416
from fastapi import FastAPI
@@ -21,8 +23,20 @@
2123
TIMER_ATTRIBUTE = "__fastapi_utils_timer__"
2224

2325

26+
@dataclass
27+
class TimingInfo:
28+
cpu_ms: float
29+
wall_ms: float
30+
name: Optional[str] = None
31+
note: Optional[str] = None
32+
33+
2434
def add_timing_middleware(
25-
app: FastAPI, record: Optional[Callable[[str], None]] = None, prefix: str = "", exclude: Optional[str] = None
35+
app: FastAPI,
36+
record: Optional[Callable[[str], None]] = None,
37+
prefix: str = "",
38+
exclude: Optional[str] = None,
39+
format_message: Optional[Callable[[TimingInfo], Any]] = None,
2640
) -> None:
2741
"""
2842
Adds a middleware to the provided `app` that records timing metrics using the provided `record` callable.
@@ -42,7 +56,7 @@ def add_timing_middleware(
4256
@app.middleware("http")
4357
async def timing_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response:
4458
metric_name = metric_namer(request.scope)
45-
with _TimingStats(metric_name, record=record, exclude=exclude) as timer:
59+
with _TimingStats(metric_name, record=record, exclude=exclude, format_message=format_message) as timer:
4660
setattr(request.state, TIMER_ATTRIBUTE, timer)
4761
response = await call_next(request)
4862
return response
@@ -65,6 +79,23 @@ def record_timing(request: Request, note: Optional[str] = None) -> None:
6579
raise ValueError("No timer present on request")
6680

6781

82+
def default_formatter(info: TimingInfo) -> str:
83+
"""
84+
Default message formatter
85+
"""
86+
message = f"TIMING: Wall: {info.wall_ms:6.1f}ms | CPU: {info.cpu_ms:6.1f}ms | {info.name}"
87+
if info.note is not None:
88+
message += f" ({info.note})"
89+
return message
90+
91+
92+
def json_formatter(info: TimingInfo) -> str:
93+
"""
94+
Format logged messages as JSON
95+
"""
96+
return json.dumps(asdict(info))
97+
98+
6899
class _TimingStats:
69100
"""
70101
This class tracks and records endpoint timing data.
@@ -81,9 +112,14 @@ class _TimingStats:
81112
"""
82113

83114
def __init__(
84-
self, name: Optional[str] = None, record: Callable[[str], None] = None, exclude: Optional[str] = None
115+
self,
116+
name: Optional[str] = None,
117+
record: Callable[[str], None] = None,
118+
exclude: Optional[str] = None,
119+
format_message: Optional[Callable[[TimingInfo], Any]] = None,
85120
) -> None:
86121
self.name = name
122+
self.format_message = format_message or default_formatter
87123
self.record = record or print
88124

89125
self.start_time: float = 0
@@ -124,11 +160,8 @@ def emit(self, note: Optional[str] = None) -> None:
124160
"""
125161
if not self.silent:
126162
self.take_split()
127-
cpu_ms = 1000 * self.cpu_time
128-
wall_ms = 1000 * self.time
129-
message = f"TIMING: Wall: {wall_ms:6.1f}ms | CPU: {cpu_ms:6.1f}ms | {self.name}"
130-
if note is not None:
131-
message += f" ({note})"
163+
info = TimingInfo(name=self.name, wall_ms=1000 * self.time, cpu_ms=1000 * self.cpu_time, note=note)
164+
message = self.format_message(info)
132165
self.record(message)
133166

134167

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ exclude = '''
9191
[tool.isort]
9292
line_length = 120
9393
known_first_party = ["fastapi-utils", "tests"]
94-
multi_line_output = 3
94+
# multi_line_output = 3
9595
include_trailing_comma = true
9696
force_grid_wrap = 0
9797
combine_as_imports = true

tests/test_timing.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from pathlib import Path
23

34
import pytest
@@ -7,7 +8,7 @@
78
from starlette.staticfiles import StaticFiles
89
from starlette.testclient import TestClient
910

10-
from fastapi_utils.timing import add_timing_middleware, record_timing
11+
from fastapi_utils.timing import TimingInfo, add_timing_middleware, json_formatter, record_timing
1112

1213
app = FastAPI()
1314
add_timing_middleware(app, exclude="untimed")
@@ -100,3 +101,53 @@ def test_recording_fails_without_middleware() -> None:
100101
with pytest.raises(ValueError) as exc_info:
101102
client3.get("/")
102103
assert str(exc_info.value) == "No timer present on request"
104+
105+
106+
app4 = FastAPI()
107+
add_timing_middleware(app4, format_message=json_formatter)
108+
109+
110+
@app4.get("/json-timed")
111+
def get_json_timed() -> None:
112+
pass
113+
114+
115+
client4 = TestClient(app4)
116+
117+
118+
def test_timing_json_formatter(capsys: CaptureFixture) -> None:
119+
client4.get("/json-timed")
120+
out, err = capsys.readouterr()
121+
assert err == ""
122+
assert out.startswith("{")
123+
json_out = json.loads(out)
124+
assert json_out["name"]
125+
assert json_out["cpu_ms"] > 0.0
126+
assert json_out["wall_ms"] > 0.0
127+
assert json_out["note"] is None
128+
129+
130+
app5 = FastAPI()
131+
132+
133+
def custom_formatter(info: TimingInfo) -> str:
134+
return f"CPU({info.cpu_ms}), WALL({info.wall_ms})"
135+
136+
137+
add_timing_middleware(app5, format_message=custom_formatter)
138+
139+
140+
@app5.get("/custom-timed")
141+
def get_alt_timed() -> None:
142+
pass
143+
144+
145+
client5 = TestClient(app5)
146+
147+
148+
def test_timing_custom_formatter(capsys: CaptureFixture) -> None:
149+
client5.get("/custom-timed")
150+
out, err = capsys.readouterr()
151+
assert err == ""
152+
assert out.startswith("CPU(")
153+
assert "WALL(" in out

0 commit comments

Comments
 (0)