Skip to content

Commit 4530ba4

Browse files
dosuken123trallnag
andauthored
feat: Instrument latency without streaming duration (#290)
* Track response start duration This commit adds a feature to track the latency excluding streaming duration. * ci(pre-commit): Apply hook auto fixes * fix: Add default to Info constructor and adjust test * fix: Make mypy happy * docs: Add parameter to docstring * fix: Add start time stuff to body handler * test: Add test * feat: Add duration stuff to default and add tests * docs: Add entry to changelog --------- Co-authored-by: Tim Schwenke <tim@trallnag.com>
1 parent c608c4e commit 4530ba4

File tree

6 files changed

+170
-10
lines changed

6 files changed

+170
-10
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0).
1919
and implementing it in
2020
[#288](https://github.com/trallnag/prometheus-fastapi-instrumentator/pull/288).
2121

22+
- **Middleware also records duration without streaming** in addition to the
23+
already existing total latency (i.e. the time consumed for streaming is not
24+
included in the duration value). The differentiation can be valuable as it
25+
shows the time to first byte.
26+
27+
This mode is opt-in and can be enabled / used in several ways: The
28+
`Instrumentator()` constructor, the `metrics.default()` closure, and the
29+
`metrics.latency()` closure now come with the flag
30+
`should_exclude_streaming_duration`. The attribute
31+
`modified_duration_without_streaming` has been added to the `metrics.Info`
32+
class. Instances of `metrics.Info` are passed to instrumentation functions,
33+
where the added value can be used to set metrics.
34+
35+
Thanks to [@dosuken123](https://github.com/dosuken123) for proposing this in
36+
[#291](https://github.com/trallnag/prometheus-fastapi-instrumentator/issues/291)
37+
and implementing it in
38+
[#290](https://github.com/trallnag/prometheus-fastapi-instrumentator/pull/290).
39+
2240
- Relaxed type of `get_route_name` argument to `HTTPConnection`. This allows
2341
developers to use the `get_route_name` function for getting the name of
2442
websocket routes as well. Thanks to [@pajowu](https://github.com/pajowu) for

src/prometheus_fastapi_instrumentator/instrumentation.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def __init__(
3333
should_round_latency_decimals: bool = False,
3434
should_respect_env_var: bool = False,
3535
should_instrument_requests_inprogress: bool = False,
36+
should_exclude_streaming_duration: bool = False,
3637
excluded_handlers: List[str] = [],
3738
body_handlers: List[str] = [],
3839
round_latency_decimals: int = 4,
@@ -69,6 +70,10 @@ def __init__(
6970
the inprogress requests. See also the related args starting
7071
with `inprogress`. Defaults to `False`.
7172
73+
should_exclude_streaming_duration: Should the streaming duration be
74+
excluded? Only relevant if default metrics are used. Defaults
75+
to `False`.
76+
7277
excluded_handlers (List[str]): List of strings that will be compiled
7378
to regex patterns. All matches will be skipped and not
7479
instrumented. Defaults to `[]`.
@@ -112,6 +117,7 @@ def __init__(
112117
self.should_round_latency_decimals = should_round_latency_decimals
113118
self.should_respect_env_var = should_respect_env_var
114119
self.should_instrument_requests_inprogress = should_instrument_requests_inprogress
120+
self.should_exclude_streaming_duration = should_exclude_streaming_duration
115121

116122
self.round_latency_decimals = round_latency_decimals
117123
self.env_var_name = env_var_name
@@ -205,6 +211,7 @@ def instrument(
205211
should_round_latency_decimals=self.should_round_latency_decimals,
206212
should_respect_env_var=self.should_respect_env_var,
207213
should_instrument_requests_inprogress=self.should_instrument_requests_inprogress,
214+
should_exclude_streaming_duration=self.should_exclude_streaming_duration,
208215
round_latency_decimals=self.round_latency_decimals,
209216
env_var_name=self.env_var_name,
210217
inprogress_name=self.inprogress_name,

src/prometheus_fastapi_instrumentator/metrics.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def __init__(
2727
modified_handler: str,
2828
modified_status: str,
2929
modified_duration: float,
30+
modified_duration_without_streaming: float = 0.0,
3031
):
3132
"""Creates Info object that is used for instrumentation functions.
3233
@@ -42,6 +43,8 @@ def __init__(
4243
by instrumentator. For example grouping into `2xx`, `3xx` and so on.
4344
modified_duration (float): Latency representation after processing
4445
by instrumentator. For example rounding of decimals. Seconds.
46+
modified_duration_without_streaming (float): Latency between request arrival and response starts (i.e. first chunk duration).
47+
Excluding the streaming duration. Defaults to 0.
4548
"""
4649

4750
self.request = request
@@ -50,6 +53,7 @@ def __init__(
5053
self.modified_handler = modified_handler
5154
self.modified_status = modified_status
5255
self.modified_duration = modified_duration
56+
self.modified_duration_without_streaming = modified_duration_without_streaming
5357

5458

5559
def _build_label_attribute_names(
@@ -114,6 +118,7 @@ def latency(
114118
should_include_handler: bool = True,
115119
should_include_method: bool = True,
116120
should_include_status: bool = True,
121+
should_exclude_streaming_duration: bool = False,
117122
buckets: Sequence[Union[float, str]] = Histogram.DEFAULT_BUCKETS,
118123
registry: CollectorRegistry = REGISTRY,
119124
) -> Optional[Callable[[Info], None]]:
@@ -141,6 +146,9 @@ def latency(
141146
should_include_status: Should the `status` label be part of the
142147
metric? Defaults to `True`.
143148
149+
should_exclude_streaming_duration: Should the streaming duration be
150+
excluded? Defaults to `False`.
151+
144152
buckets: Buckets for the histogram. Defaults to Prometheus default.
145153
Defaults to default buckets from Prometheus client library.
146154
@@ -184,15 +192,21 @@ def latency(
184192
)
185193

186194
def instrumentation(info: Info) -> None:
195+
duration = info.modified_duration
196+
if should_exclude_streaming_duration:
197+
duration = info.modified_duration_without_streaming
198+
else:
199+
duration = info.modified_duration
200+
187201
if label_names:
188202
label_values = [
189203
getattr(info, attribute_name)
190204
for attribute_name in info_attribute_names
191205
]
192206

193-
METRIC.labels(*label_values).observe(info.modified_duration)
207+
METRIC.labels(*label_values).observe(duration)
194208
else:
195-
METRIC.observe(info.modified_duration)
209+
METRIC.observe(duration)
196210

197211
return instrumentation
198212
except ValueError as e:
@@ -569,6 +583,7 @@ def default(
569583
metric_namespace: str = "",
570584
metric_subsystem: str = "",
571585
should_only_respect_2xx_for_highr: bool = False,
586+
should_exclude_streaming_duration: bool = False,
572587
latency_highr_buckets: Sequence[Union[float, str]] = (
573588
0.01,
574589
0.025,
@@ -610,7 +625,7 @@ def default(
610625
content length bytes by handler.
611626
* `http_request_duration_highr_seconds` (no labels): High number of buckets
612627
leading to more accurate calculation of percentiles.
613-
* `http_request_duration_seconds` (`handler`):
628+
* `http_request_duration_seconds` (`handler`, `method`):
614629
Kepp the bucket count very low. Only put in SLIs.
615630
616631
Args:
@@ -625,6 +640,9 @@ def default(
625640
requests / responses that have a status code starting with `2`?
626641
Defaults to `False`.
627642
643+
should_exclude_streaming_duration: Should the streaming duration be
644+
excluded? Defaults to `False`.
645+
628646
latency_highr_buckets (tuple[float], optional): Buckets tuple for high
629647
res histogram. Can be large because no labels are used. Defaults to
630648
(0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 2.5,
@@ -719,6 +737,12 @@ def default(
719737
)
720738

721739
def instrumentation(info: Info) -> None:
740+
duration = info.modified_duration
741+
if should_exclude_streaming_duration:
742+
duration = info.modified_duration_without_streaming
743+
else:
744+
duration = info.modified_duration
745+
722746
TOTAL.labels(info.method, info.modified_status, info.modified_handler).inc()
723747

724748
IN_SIZE.labels(info.modified_handler).observe(
@@ -735,11 +759,11 @@ def instrumentation(info: Info) -> None:
735759
if not should_only_respect_2xx_for_highr or info.modified_status.startswith(
736760
"2"
737761
):
738-
LATENCY_HIGHR.observe(info.modified_duration)
762+
LATENCY_HIGHR.observe(duration)
739763

740764
LATENCY_LOWR.labels(
741765
handler=info.modified_handler, method=info.method
742-
).observe(info.modified_duration)
766+
).observe(duration)
743767

744768
return instrumentation
745769

src/prometheus_fastapi_instrumentator/middleware.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def __init__(
2727
should_round_latency_decimals: bool = False,
2828
should_respect_env_var: bool = False,
2929
should_instrument_requests_inprogress: bool = False,
30+
should_exclude_streaming_duration: bool = False,
3031
excluded_handlers: Sequence[str] = (),
3132
body_handlers: Sequence[str] = (),
3233
round_latency_decimals: int = 4,
@@ -89,6 +90,7 @@ def __init__(
8990
metric_namespace=metric_namespace,
9091
metric_subsystem=metric_subsystem,
9192
should_only_respect_2xx_for_highr=should_only_respect_2xx_for_highr,
93+
should_exclude_streaming_duration=should_exclude_streaming_duration,
9294
latency_highr_buckets=latency_highr_buckets,
9395
latency_lowr_buckets=latency_lowr_buckets,
9496
registry=self.registry,
@@ -140,15 +142,17 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
140142
status_code = 500
141143
headers = []
142144
body = b""
145+
response_start_time = None
143146

144147
# Message body collected for handlers matching body_handlers patterns.
145148
if any(pattern.search(handler) for pattern in self.body_handlers):
146149

147150
async def send_wrapper(message: Message) -> None:
148151
if message["type"] == "http.response.start":
149-
nonlocal status_code, headers
152+
nonlocal status_code, headers, response_start_time
150153
headers = message["headers"]
151154
status_code = message["status"]
155+
response_start_time = default_timer()
152156
elif message["type"] == "http.response.body" and message["body"]:
153157
nonlocal body
154158
body += message["body"]
@@ -158,9 +162,10 @@ async def send_wrapper(message: Message) -> None:
158162

159163
async def send_wrapper(message: Message) -> None:
160164
if message["type"] == "http.response.start":
161-
nonlocal status_code, headers
165+
nonlocal status_code, headers, response_start_time
162166
headers = message["headers"]
163167
status_code = message["status"]
168+
response_start_time = default_timer()
164169
await send(message)
165170

166171
try:
@@ -175,13 +180,22 @@ async def send_wrapper(message: Message) -> None:
175180
)
176181

177182
if not is_excluded:
178-
duration = max(default_timer() - start_time, 0)
183+
duration = max(default_timer() - start_time, 0.0)
184+
duration_without_streaming = 0.0
185+
186+
if response_start_time:
187+
duration_without_streaming = max(
188+
response_start_time - start_time, 0.0
189+
)
179190

180191
if self.should_instrument_requests_inprogress:
181192
inprogress.dec()
182193

183194
if self.should_round_latency_decimals:
184195
duration = round(duration, self.round_latency_decimals)
196+
duration_without_streaming = round(
197+
duration_without_streaming, self.round_latency_decimals
198+
)
185199

186200
if self.should_group_status_codes:
187201
status = status[0] + "xx"
@@ -197,6 +211,7 @@ async def send_wrapper(message: Message) -> None:
197211
modified_handler=handler,
198212
modified_status=status,
199213
modified_duration=duration,
214+
modified_duration_without_streaming=duration_without_streaming,
200215
)
201216

202217
for instrumentation in self.instrumentations:

tests/test_metrics.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from typing import Any, Dict, Optional
22

33
import pytest
4-
from fastapi import FastAPI, HTTPException
5-
from prometheus_client import REGISTRY
4+
from fastapi import FastAPI, HTTPException, responses
5+
from prometheus_client import REGISTRY, Histogram
66
from requests import Response as TestClientResponse
77
from starlette.testclient import TestClient
88

@@ -106,6 +106,7 @@ def test_existence_of_attributes():
106106
assert info.modified_duration is None
107107
assert info.modified_status is None
108108
assert info.modified_handler is None
109+
assert info.modified_duration_without_streaming == 0.0
109110

110111

111112
def test_build_label_attribute_names_all_false():
@@ -422,6 +423,47 @@ def test_latency_with_bucket_no_inf():
422423
)
423424

424425

426+
def test_latency_duration_without_streaming():
427+
_ = create_app()
428+
app = FastAPI()
429+
client = TestClient(app)
430+
431+
@app.get("/")
432+
def root():
433+
return responses.StreamingResponse(("x" * 1_000 for _ in range(5)))
434+
435+
METRIC = Histogram(
436+
"http_request_duration_with_streaming_seconds",
437+
"x",
438+
)
439+
440+
def instrumentation(info: metrics.Info) -> None:
441+
METRIC.observe(info.modified_duration)
442+
443+
Instrumentator().add(
444+
metrics.latency(
445+
should_include_handler=False,
446+
should_include_method=False,
447+
should_include_status=False,
448+
should_exclude_streaming_duration=True,
449+
),
450+
instrumentation,
451+
).instrument(app).expose(app)
452+
client = TestClient(app)
453+
454+
client.get("/")
455+
456+
_ = get_response(client, "/metrics")
457+
458+
assert REGISTRY.get_sample_value(
459+
"http_request_duration_seconds_sum",
460+
{},
461+
) < REGISTRY.get_sample_value(
462+
"http_request_duration_with_streaming_seconds_sum",
463+
{},
464+
)
465+
466+
425467
# ------------------------------------------------------------------------------
426468
# default
427469

@@ -521,6 +563,39 @@ def test_default_with_runtime_error():
521563
)
522564

523565

566+
def test_default_duration_without_streaming():
567+
_ = create_app()
568+
app = FastAPI()
569+
570+
@app.get("/")
571+
def root():
572+
return responses.StreamingResponse(("x" * 1_000 for _ in range(5)))
573+
574+
METRIC = Histogram(
575+
"http_request_duration_with_streaming_seconds", "x", labelnames=["handler"]
576+
)
577+
578+
def instrumentation(info: metrics.Info) -> None:
579+
METRIC.labels(info.modified_handler).observe(info.modified_duration)
580+
581+
Instrumentator().add(
582+
metrics.default(should_exclude_streaming_duration=True), instrumentation
583+
).instrument(app).expose(app)
584+
client = TestClient(app)
585+
586+
client.get("/")
587+
588+
_ = get_response(client, "/metrics")
589+
590+
assert REGISTRY.get_sample_value(
591+
"http_request_duration_with_streaming_seconds_sum",
592+
{"handler": "/"},
593+
) > REGISTRY.get_sample_value(
594+
"http_request_duration_seconds_sum",
595+
{"handler": "/", "method": "GET"},
596+
)
597+
598+
524599
# ------------------------------------------------------------------------------
525600
# requests
526601

tests/test_middleware.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,24 @@ def instrumentation(info: metrics.Info) -> None:
163163
response = client.get("/")
164164
assert instrumentation_executed
165165
assert len(response.content) == 5_000_000
166+
167+
168+
def test_info_body_duration_without_streaming():
169+
app = FastAPI()
170+
client = TestClient(app)
171+
172+
@app.get("/")
173+
def root():
174+
return responses.StreamingResponse(("x" * 1_000 for _ in range(5)))
175+
176+
instrumentation_executed = False
177+
178+
def instrumentation(info: metrics.Info) -> None:
179+
nonlocal instrumentation_executed
180+
instrumentation_executed = True
181+
assert info.modified_duration_without_streaming < info.modified_duration
182+
183+
Instrumentator(body_handlers=[r".*"]).instrument(app).add(instrumentation)
184+
185+
client.get("/")
186+
assert instrumentation_executed

0 commit comments

Comments
 (0)