Skip to content
This repository was archived by the owner on Sep 2, 2022. It is now read-only.

Commit fe10da2

Browse files
authored
Merge pull request #10 from apilytics/request-response-sizes
Send request and response body size information with metrics
2 parents f3ad204 + ae90f03 commit fe10da2

File tree

10 files changed

+245
-19
lines changed

10 files changed

+245
-19
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Send request and response body size information with metrics.
13+
14+
### Changed
15+
16+
- Change `status_code` into an optional parameter in `ApilyticsSender.set_response_info`.
17+
1018
## [1.1.0] - 2022-01-16
1119

1220
### Added

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ import os
3737
APILYTICS_API_KEY = os.getenv("APILYTICS_API_KEY")
3838

3939
MIDDLEWARE = [
40-
"apilytics.django.ApilyticsMiddleware",
40+
"apilytics.django.ApilyticsMiddleware", # Ideally the first middleware in the list.
41+
# ...
4142
]
4243
```
4344

@@ -53,6 +54,7 @@ from fastapi import FastAPI
5354

5455
app = FastAPI()
5556

57+
# Ideally the first middleware you add.
5658
app.add_middleware(ApilyticsMiddleware, api_key=os.getenv("APILYTICS_API_KEY"))
5759
```
5860

@@ -78,9 +80,13 @@ def my_apilytics_middleware(request, get_response):
7880
path=request.path,
7981
query=request.query_string,
8082
method=request.method,
83+
request_size=len(request.body),
8184
) as sender:
8285
response = get_response(request)
83-
sender.set_response_info(status_code=response.status_code)
86+
sender.set_response_info(
87+
status_code=response.status_code,
88+
response_size=len(response.body),
89+
)
8490
return response
8591
```
8692

apilytics/core.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ class ApilyticsSender:
2929
path=request.path,
3030
query=request.query_string,
3131
method=request.method,
32+
request_size=len(request.body),
3233
) as sender:
3334
response = get_response(request)
34-
sender.set_response_info(status_code=response.status_code)
35+
sender.set_response_info(
36+
status_code=response.status_code,
37+
response_size=len(response.body),
38+
)
3539
"""
3640

3741
_executor: ClassVar[concurrent.futures.Executor]
@@ -47,6 +51,7 @@ def __init__(
4751
path: str,
4852
method: str,
4953
query: Optional[str] = None,
54+
request_size: Optional[int] = None,
5055
apilytics_integration: Optional[str] = None,
5156
integrated_library: Optional[str] = None,
5257
) -> None:
@@ -59,6 +64,7 @@ def __init__(
5964
method: Method of the user's HTTP request, e.g. "GET".
6065
query: Optional query string of the user's HTTP request e.g. "key=val&other=123".
6166
An empty string and None are treated equally. Can have an optional "?" at the start.
67+
request_size: Size of the user's HTTP request's body in bytes.
6268
apilytics_integration: Name of the Apilytics integration that's calling this,
6369
e.g. "apilytics-python-django". No need to pass this when calling from user code.
6470
integrated_library: Name and version of the integration that this is used in,
@@ -68,6 +74,9 @@ def __init__(
6874
self._path = path
6975
self._method = method
7076
self._query = query
77+
self._request_size = request_size
78+
79+
self._response_size: Optional[int] = None
7180
self._status_code: Optional[int] = None
7281

7382
self._apilytics_version = self._apilytics_version_template.format(
@@ -97,16 +106,21 @@ def __exit__(
97106
)
98107
self._executor.submit(self._send_metrics)
99108

100-
def set_response_info(self, *, status_code: int) -> None:
109+
def set_response_info(
110+
self, *, status_code: Optional[int] = None, response_size: Optional[int] = None
111+
) -> None:
101112
"""
102113
Update the context manager with info from the HTTP response object.
103114
104115
Should be called before the context manager's block ends.
105116
106117
Args:
107-
status_code: Status code for the HTTP response.
118+
status_code: Status code for the HTTP response. Can be omitted (or None)
119+
if the middleware could not get the status code.
120+
response_size: Size of the body of the sent HTTP response in bytes.
108121
"""
109122
self._status_code = status_code
123+
self._response_size = response_size
110124

111125
def _send_metrics(self) -> None:
112126
request = urllib.request.Request(
@@ -120,10 +134,24 @@ def _send_metrics(self) -> None:
120134
)
121135
data = {
122136
"path": self._path,
123-
**({"query": self._query} if self._query else {}),
124137
"method": self._method,
125-
"statusCode": self._status_code,
126138
"timeMillis": (self._end_time_ns - self._start_time_ns) // 1_000_000,
139+
**({"query": self._query} if self._query else {}), # Don't send empty str.
140+
**(
141+
{"statusCode": self._status_code}
142+
if self._status_code is not None
143+
else {}
144+
),
145+
**(
146+
{"requestSize": self._request_size}
147+
if self._request_size is not None
148+
else {}
149+
),
150+
**(
151+
{"responseSize": self._response_size}
152+
if self._response_size is not None
153+
else {}
154+
),
127155
}
128156
try:
129157
urllib.request.urlopen(url=request, data=json.dumps(data).encode())

apilytics/django.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class ApilyticsMiddleware:
1414
"""
1515
Django middleware that sends API analytics data to Apilytics (https://apilytics.io).
1616
17+
This should ideally be the first middleware in your ``settings.MIDDLEWARE`` list.
18+
1719
Requires your Apilytics origin's API key to be set as ``APILYTICS_API_KEY`` in
1820
``settings.py`` for this to do anything. The variable can be unset (or ``None``)
1921
e.g. in a test environment where data should not be sent.
@@ -25,6 +27,7 @@ class ApilyticsMiddleware:
2527
2628
MIDDLEWARE = [
2729
"apilytics.django.ApilyticsMiddleware",
30+
# ...
2831
]
2932
"""
3033

@@ -44,9 +47,13 @@ def __call__(self, request: django.http.HttpRequest) -> django.http.HttpResponse
4447
path=request.path,
4548
query=request.META.get("QUERY_STRING"),
4649
method=request.method or "", # Typed as Optional, should never be None.
50+
request_size=int(request.META.get("CONTENT_LENGTH", 0)),
4751
apilytics_integration="apilytics-python-django",
4852
integrated_library=f"django/{django.__version__}",
4953
) as sender:
5054
response = self.get_response(request)
51-
sender.set_response_info(status_code=response.status_code)
55+
sender.set_response_info(
56+
status_code=response.status_code,
57+
response_size=len(getattr(response, "content", ())),
58+
)
5259
return response

apilytics/fastapi.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class ApilyticsMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
1212
"""
1313
FastAPI middleware that sends API analytics data to Apilytics (https://apilytics.io).
1414
15+
This should ideally be the first middleware you add to your app.
16+
1517
Examples:
1618
main.py::
1719
@@ -53,9 +55,13 @@ async def dispatch(
5355
path=request.url.path,
5456
query=request.url.query,
5557
method=request.method,
58+
request_size=len(await request.body()),
5659
apilytics_integration="apilytics-python-fastapi",
5760
integrated_library=f"fastapi/{fastapi.__version__}",
5861
) as sender:
5962
response = await call_next(request)
60-
sender.set_response_info(status_code=response.status_code)
63+
sender.set_response_info(
64+
status_code=response.status_code,
65+
response_size=int(response.headers.get("content-length", 0)),
66+
)
6167
return response

tests/django/app/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44

55
urlpatterns = [
66
django.urls.re_path(r"^error/?$", tests.django.app.views.error_view),
7+
django.urls.re_path(r"^empty/?$", tests.django.app.views.no_body_view),
8+
django.urls.re_path(r"^streaming/?$", tests.django.app.views.streaming_view),
79
django.urls.re_path(r"^.*$", tests.django.app.views.ok_view),
810
]

tests/django/app/views.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,23 @@
33
import django.http
44

55

6-
def ok_view(request: django.http.HttpRequest) -> django.http.HttpResponse:
7-
if request.method == "POST":
8-
return django.http.HttpResponse(status=201)
6+
def error_view(request: django.http.HttpRequest) -> NoReturn:
7+
raise RuntimeError
8+
9+
10+
def no_body_view(request: django.http.HttpRequest) -> django.http.HttpResponse:
911
return django.http.HttpResponse(status=200)
1012

1113

12-
def error_view(request: django.http.HttpRequest) -> NoReturn:
13-
raise RuntimeError
14+
def streaming_view(
15+
request: django.http.HttpRequest,
16+
) -> django.http.StreamingHttpResponse:
17+
return django.http.StreamingHttpResponse(
18+
status=200, streaming_content=(b"first", b"second")
19+
)
20+
21+
22+
def ok_view(request: django.http.HttpRequest) -> django.http.HttpResponse:
23+
if request.method == "POST":
24+
return django.http.HttpResponse(status=201, content=b"created")
25+
return django.http.HttpResponse(status=200, content=b"ok")

tests/django/test_django.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,19 @@ def test_middleware_should_call_apilytics_api(
3636
}
3737

3838
data = tests.conftest.decode_request_data(call_kwargs["data"])
39-
assert data.keys() == {"path", "method", "statusCode", "timeMillis"}
39+
assert data.keys() == {
40+
"path",
41+
"method",
42+
"statusCode",
43+
"requestSize",
44+
"responseSize",
45+
"timeMillis",
46+
}
4047
assert data["path"] == "/"
4148
assert data["method"] == "GET"
4249
assert data["statusCode"] == 200
50+
assert data["requestSize"] == 0
51+
assert data["responseSize"] > 0
4352
assert isinstance(data["timeMillis"], int)
4453

4554

@@ -57,6 +66,64 @@ def test_middleware_should_send_query_params(
5766
assert data["path"] == "/dummy/123/path/"
5867
assert data["query"] == "param=foo&param2=bar"
5968
assert data["statusCode"] == 201
69+
assert data["requestSize"] == 20 # Empty form data POST adds a 20 boundary string.
70+
assert data["responseSize"] > 0
71+
assert isinstance(data["timeMillis"], int)
72+
73+
74+
def test_middleware_should_send_zero_request_and_response_sizes(
75+
mocked_urlopen: unittest.mock.MagicMock,
76+
) -> None:
77+
client.handler.load_middleware()
78+
response = client.post("/empty?some=query", content_type="application/json")
79+
assert response.status_code == 200
80+
81+
assert mocked_urlopen.call_count == 1
82+
__, call_kwargs = mocked_urlopen.call_args
83+
data = tests.conftest.decode_request_data(call_kwargs["data"])
84+
assert data["requestSize"] == 2 # Django makes it `b"{}"` for empty JSON POSTs.
85+
assert data["responseSize"] == 0
86+
87+
88+
def test_middleware_should_send_non_zero_request_and_response_sizes(
89+
mocked_urlopen: unittest.mock.MagicMock,
90+
) -> None:
91+
client.handler.load_middleware()
92+
response = client.post(
93+
"/dummy?some=query", data={"hello": "world"}, content_type="application/json"
94+
)
95+
assert response.status_code == 201
96+
97+
assert mocked_urlopen.call_count == 1
98+
__, call_kwargs = mocked_urlopen.call_args
99+
data = tests.conftest.decode_request_data(call_kwargs["data"])
100+
assert data["requestSize"] == 18
101+
assert data["responseSize"] == 7 # `len(b"created")`
102+
103+
104+
def test_middleware_should_work_with_streaming_response(
105+
mocked_urlopen: unittest.mock.MagicMock,
106+
) -> None:
107+
client.handler.load_middleware()
108+
response = client.get("/streaming")
109+
assert response.status_code == 200
110+
111+
assert mocked_urlopen.call_count == 1
112+
__, call_kwargs = mocked_urlopen.call_args
113+
data = tests.conftest.decode_request_data(call_kwargs["data"])
114+
assert data.keys() == {
115+
"path",
116+
"method",
117+
"statusCode",
118+
"requestSize",
119+
"responseSize",
120+
"timeMillis",
121+
}
122+
assert data["path"] == "/streaming"
123+
assert data["method"] == "GET"
124+
assert data["statusCode"] == 200
125+
assert data["requestSize"] == 0
126+
assert data["responseSize"] == 0 # Can't get body size from a streaming response.
60127
assert isinstance(data["timeMillis"], int)
61128

62129

@@ -84,7 +151,17 @@ def test_middleware_should_send_data_even_on_errors(
84151

85152
__, call_kwargs = mocked_urlopen.call_args
86153
data = tests.conftest.decode_request_data(call_kwargs["data"])
154+
assert data.keys() == {
155+
"method",
156+
"path",
157+
"timeMillis",
158+
"statusCode",
159+
"requestSize",
160+
"responseSize",
161+
}
87162
assert data["method"] == "GET"
88163
assert data["path"] == "/error"
89164
assert data["statusCode"] == 500
165+
assert data["requestSize"] == 0
166+
assert data["responseSize"] > 0
90167
assert isinstance(data["timeMillis"], int)

tests/fastapi/app.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import NoReturn
33

44
import fastapi
5+
import starlette.responses
56

67
import apilytics.fastapi
78

@@ -15,8 +16,24 @@ async def error_route(request: fastapi.Request) -> NoReturn:
1516
raise RuntimeError
1617

1718

19+
@app.api_route("/empty", methods=["POST"])
20+
async def no_body_route(request: fastapi.Request) -> fastapi.Response:
21+
return fastapi.Response(status_code=fastapi.status.HTTP_200_OK)
22+
23+
24+
@app.api_route("/streaming", methods=["GET"])
25+
async def streaming_route(
26+
request: fastapi.Request,
27+
) -> starlette.responses.StreamingResponse:
28+
return starlette.responses.StreamingResponse(
29+
status_code=fastapi.status.HTTP_200_OK, content=(b"first", b"second")
30+
)
31+
32+
1833
@app.api_route("/{path:path}", methods=["GET", "POST"])
1934
async def ok_route(request: fastapi.Request) -> fastapi.Response:
2035
if request.method == "POST":
21-
return fastapi.Response(status_code=fastapi.status.HTTP_201_CREATED)
22-
return fastapi.Response(status_code=fastapi.status.HTTP_200_OK)
36+
return fastapi.Response(
37+
status_code=fastapi.status.HTTP_201_CREATED, content=b"created"
38+
)
39+
return fastapi.Response(status_code=fastapi.status.HTTP_200_OK, content=b"ok")

0 commit comments

Comments
 (0)