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

Commit 2e36b87

Browse files
committed
Send memory usage information together with metrics on Linux
We handle only Linux since getting accurate memory usage values on it is most important. Memory cannot be read platform independently in Python and we don't want at this point to include any external dependencies, such as `psutil`. We might want to add support for other systems when the need arises for some user, it likely won't happen soon since since production web servers are not often ran on non-Linux systems. Good to note that `MemAvailable` in `/proc/meminfo` is only available on Linux kernel 3.14 and up, but since 3.14 has already reached end of life we can pretty safely ignore support for systems before it.
1 parent a6f220d commit 2e36b87

5 files changed

Lines changed: 217 additions & 4 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Send current system memory usage and total available memory together with metrics on Linux systems.
13+
1014
## [1.3.0] - 2022-02-02
1115

1216
### Added

apilytics/core.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import concurrent.futures
22
import json
33
import platform
4+
import re
45
import time
56
import types
67
import urllib.error
78
import urllib.request
8-
from typing import ClassVar, Optional, Type
9+
from typing import ClassVar, Optional, Tuple, Type
910

1011
import apilytics
1112

@@ -127,6 +128,8 @@ def set_response_info(
127128
self._response_size = response_size
128129

129130
def _send_metrics(self) -> None:
131+
memory_usage, memory_total = _get_used_and_total_memory()
132+
130133
request = urllib.request.Request(
131134
url="https://www.apilytics.io/api/v1/middleware",
132135
method="POST",
@@ -158,8 +161,42 @@ def _send_metrics(self) -> None:
158161
if self._response_size is not None
159162
else {}
160163
),
164+
**({"memoryUsage": memory_usage} if memory_usage is not None else {}),
165+
**({"memoryTotal": memory_total} if memory_total is not None else {}),
161166
}
162167
try:
163168
urllib.request.urlopen(url=request, data=json.dumps(data).encode())
164169
except urllib.error.URLError:
165170
pass
171+
172+
173+
def _get_used_and_total_memory() -> Tuple[Optional[int], Optional[int]]:
174+
"""
175+
Get information about the used and total system memory.
176+
177+
Returns:
178+
A tuple containing the used and total system memory in bytes.
179+
(None, None) if the system is not Linux or if the reading fails.
180+
(None, int) if the used memory could not be determined.
181+
"""
182+
used = None
183+
total = None
184+
185+
if platform.system() == "Linux":
186+
try:
187+
with open("/proc/meminfo") as f:
188+
meminfo = f.read()
189+
except OSError:
190+
pass # Prepare for everything and anything.
191+
else:
192+
total_match = re.search(r"MemTotal:\s*(\d+)", meminfo)
193+
available_match = re.search(r"MemAvailable:\s*(\d+)", meminfo)
194+
if total_match:
195+
total = int(total_match.group(1)) * 1024
196+
if available_match:
197+
# If MemAvailable exists MemTotal will also exist.
198+
# The reverse is not always true (MemAvailable came in Linux 3.14).
199+
available = int(available_match.group(1)) * 1024
200+
used = total - available
201+
202+
return used, total

tests/django/test_django.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,17 @@ def test_middleware_should_call_apilytics_api(
3838
"requestSize",
3939
"responseSize",
4040
"timeMillis",
41+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
4142
}
4243
assert data["path"] == "/"
4344
assert data["method"] == "GET"
4445
assert data["statusCode"] == 200
4546
assert data["requestSize"] == 0
4647
assert data["responseSize"] > 0
4748
assert isinstance(data["timeMillis"], int)
49+
if platform.system() == "Linux":
50+
assert isinstance(data["memoryUsage"], int)
51+
assert isinstance(data["memoryTotal"], int)
4852

4953

5054
def test_middleware_should_send_query_params(
@@ -123,12 +127,16 @@ def test_middleware_should_work_with_streaming_response(
123127
"statusCode",
124128
"requestSize",
125129
"timeMillis",
130+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
126131
}
127132
assert data["path"] == "/streaming"
128133
assert data["method"] == "GET"
129134
assert data["statusCode"] == 200
130135
assert data["requestSize"] == 0
131136
assert isinstance(data["timeMillis"], int)
137+
if platform.system() == "Linux":
138+
assert isinstance(data["memoryUsage"], int)
139+
assert isinstance(data["memoryTotal"], int)
132140

133141

134142
@django.test.override_settings(APILYTICS_API_KEY=None)
@@ -160,10 +168,14 @@ def test_middleware_should_send_data_even_on_errors(
160168
"statusCode",
161169
"requestSize",
162170
"responseSize",
171+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
163172
}
164173
assert data["method"] == "GET"
165174
assert data["path"] == "/error"
166175
assert data["statusCode"] == 500
167176
assert data["requestSize"] == 0
168177
assert data["responseSize"] > 0
169178
assert isinstance(data["timeMillis"], int)
179+
if platform.system() == "Linux":
180+
assert isinstance(data["memoryUsage"], int)
181+
assert isinstance(data["memoryTotal"], int)

tests/fastapi/test_fastapi.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def test_middleware_should_call_apilytics_api(
4444
"responseSize",
4545
"userAgent",
4646
"timeMillis",
47+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
4748
}
4849
assert data["path"] == "/"
4950
assert data["method"] == "GET"
@@ -52,6 +53,9 @@ def test_middleware_should_call_apilytics_api(
5253
assert data["responseSize"] > 0
5354
assert data["userAgent"] == "testclient"
5455
assert isinstance(data["timeMillis"], int)
56+
if platform.system() == "Linux":
57+
assert isinstance(data["memoryUsage"], int)
58+
assert isinstance(data["memoryTotal"], int)
5559

5660

5761
def test_middleware_should_send_query_params(
@@ -128,13 +132,17 @@ def test_middleware_should_work_with_streaming_response(
128132
"requestSize",
129133
"userAgent",
130134
"timeMillis",
135+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
131136
}
132137
assert data["path"] == "/streaming"
133138
assert data["method"] == "GET"
134139
assert data["statusCode"] == 200
135140
assert data["requestSize"] == 0
136141
assert data["userAgent"] == "testclient"
137142
assert isinstance(data["timeMillis"], int)
143+
if platform.system() == "Linux":
144+
assert isinstance(data["memoryUsage"], int)
145+
assert isinstance(data["memoryTotal"], int)
138146

139147

140148
@tests.fastapi.conftest.override_middleware(
@@ -166,9 +174,19 @@ def test_middleware_should_send_data_even_on_errors(
166174

167175
__, call_kwargs = mocked_urlopen.call_args
168176
data = tests.conftest.decode_request_data(call_kwargs["data"])
169-
assert data.keys() == {"method", "path", "timeMillis", "userAgent", "requestSize"}
177+
assert data.keys() == {
178+
"method",
179+
"path",
180+
"timeMillis",
181+
"userAgent",
182+
"requestSize",
183+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
184+
}
170185
assert data["method"] == "GET"
171186
assert data["path"] == "/error"
172187
assert data["requestSize"] == 0
173188
assert data["userAgent"] == "testclient"
174189
assert isinstance(data["timeMillis"], int)
190+
if platform.system() == "Linux":
191+
assert isinstance(data["memoryUsage"], int)
192+
assert isinstance(data["memoryTotal"], int)

tests/test_core.py

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import platform
2+
import textwrap
23
import unittest.mock
34
import urllib.error
45

@@ -34,11 +35,20 @@ def test_apilytics_sender_should_call_apilytics_api(
3435
}
3536

3637
data = tests.conftest.decode_request_data(call_kwargs["data"])
37-
assert data.keys() == {"path", "method", "statusCode", "timeMillis"}
38+
assert data.keys() == {
39+
"path",
40+
"method",
41+
"statusCode",
42+
"timeMillis",
43+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
44+
}
3845
assert data["path"] == "/"
3946
assert data["method"] == "GET"
4047
assert data["statusCode"] == 200
4148
assert isinstance(data["timeMillis"], int)
49+
if platform.system() == "Linux":
50+
assert data["memoryUsage"] > 0
51+
assert data["memoryTotal"] > data["memoryUsage"]
4252

4353

4454
def test_apilytics_sender_should_send_query_params(
@@ -107,10 +117,142 @@ def test_apilytics_sender_should_handle_empty_values_correctly(
107117
assert mocked_urlopen.call_count == 1
108118
__, call_kwargs = mocked_urlopen.call_args
109119
data = tests.conftest.decode_request_data(call_kwargs["data"])
110-
assert data.keys() == {"path", "method", "timeMillis"}
120+
assert data.keys() == {
121+
"path",
122+
"method",
123+
"timeMillis",
124+
*(("memoryUsage", "memoryTotal") if platform.system() == "Linux" else ()),
125+
}
111126
assert data["path"] == ""
112127
assert data["method"] == ""
113128
assert isinstance(data["timeMillis"], int)
129+
if platform.system() == "Linux":
130+
assert isinstance(data["memoryUsage"], int)
131+
assert isinstance(data["memoryTotal"], int)
132+
133+
134+
@unittest.mock.patch("apilytics.core.platform.system", return_value="Linux")
135+
def test_apilytics_sender_should_read_proc_meminfo_on_linux(
136+
_mocked_system: unittest.mock.MagicMock,
137+
mocked_urlopen: unittest.mock.MagicMock,
138+
) -> None:
139+
memory_total = 4_125_478_912
140+
memory_available = 3_360_526_336
141+
142+
mocked_meminfo = textwrap.dedent(
143+
f"""\
144+
MemTotal: {memory_total // 1024} kB
145+
MemFree: 789940 kB
146+
MemAvailable: {memory_available // 1024} kB
147+
Buffers: 2450168 kB
148+
""" # The real file is longer.
149+
)
150+
with unittest.mock.patch(
151+
"builtins.open", new=unittest.mock.mock_open(read_data=mocked_meminfo)
152+
) as mocked_open:
153+
with apilytics.core.ApilyticsSender(
154+
api_key="dummy-key",
155+
path="/",
156+
method="GET",
157+
) as sender:
158+
sender.set_response_info(status_code=200)
159+
160+
assert mocked_open.call_count == 1
161+
assert mocked_urlopen.call_count == 1
162+
__, call_kwargs = mocked_urlopen.call_args
163+
data = tests.conftest.decode_request_data(call_kwargs["data"])
164+
assert data["memoryUsage"] == memory_total - memory_available
165+
assert data["memoryTotal"] == memory_total
166+
167+
168+
@unittest.mock.patch("apilytics.core.platform.system", return_value="Linux")
169+
def test_apilytics_sender_should_handle_proc_meminfo_read_failure(
170+
_mocked_system: unittest.mock.MagicMock,
171+
mocked_urlopen: unittest.mock.MagicMock,
172+
) -> None:
173+
with unittest.mock.patch("builtins.open", side_effect=OSError) as mocked_open:
174+
with apilytics.core.ApilyticsSender(
175+
api_key="dummy-key",
176+
path="/",
177+
method="GET",
178+
) as sender:
179+
sender.set_response_info(status_code=200)
180+
181+
assert mocked_open.call_count == 1
182+
assert mocked_urlopen.call_count == 1
183+
__, call_kwargs = mocked_urlopen.call_args
184+
data = tests.conftest.decode_request_data(call_kwargs["data"])
185+
assert "memoryUsage" not in data
186+
assert "memoryTotal" not in data
187+
188+
189+
@unittest.mock.patch("apilytics.core.platform.system", return_value="Linux")
190+
def test_apilytics_sender_should_handle_proc_meminfo_total_missing(
191+
_mocked_system: unittest.mock.MagicMock,
192+
mocked_urlopen: unittest.mock.MagicMock,
193+
) -> None:
194+
with unittest.mock.patch(
195+
"builtins.open", new=unittest.mock.mock_open(read_data="")
196+
) as mocked_open:
197+
with apilytics.core.ApilyticsSender(
198+
api_key="dummy-key",
199+
path="/",
200+
method="GET",
201+
) as sender:
202+
sender.set_response_info(status_code=200)
203+
204+
assert mocked_open.call_count == 1
205+
assert mocked_urlopen.call_count == 1
206+
__, call_kwargs = mocked_urlopen.call_args
207+
data = tests.conftest.decode_request_data(call_kwargs["data"])
208+
assert "memoryUsage" not in data
209+
assert "memoryTotal" not in data
210+
211+
212+
@unittest.mock.patch("apilytics.core.platform.system", return_value="Linux")
213+
def test_apilytics_sender_should_handle_proc_meminfo_available_missing(
214+
_mocked_system: unittest.mock.MagicMock,
215+
mocked_urlopen: unittest.mock.MagicMock,
216+
) -> None:
217+
memory_total = 1048576
218+
with unittest.mock.patch(
219+
"builtins.open",
220+
new=unittest.mock.mock_open(read_data=f"MemTotal: {memory_total // 1024}"),
221+
) as mocked_open:
222+
with apilytics.core.ApilyticsSender(
223+
api_key="dummy-key",
224+
path="/",
225+
method="GET",
226+
) as sender:
227+
sender.set_response_info(status_code=200)
228+
229+
assert mocked_open.call_count == 1
230+
assert mocked_urlopen.call_count == 1
231+
__, call_kwargs = mocked_urlopen.call_args
232+
data = tests.conftest.decode_request_data(call_kwargs["data"])
233+
assert "memoryUsage" not in data
234+
assert data["memoryTotal"] == memory_total
235+
236+
237+
@unittest.mock.patch("apilytics.core.platform.system", return_value="Windows")
238+
def test_apilytics_sender_should_not_read_proc_meminfo_when_not_on_linux(
239+
_mocked_system: unittest.mock.MagicMock,
240+
mocked_urlopen: unittest.mock.MagicMock,
241+
) -> None:
242+
with unittest.mock.patch("builtins.open") as mocked_open:
243+
with apilytics.core.ApilyticsSender(
244+
api_key="dummy-key",
245+
path="/",
246+
method="GET",
247+
) as sender:
248+
sender.set_response_info(status_code=200)
249+
250+
assert mocked_open.call_count == 0
251+
assert mocked_urlopen.call_count == 1
252+
__, call_kwargs = mocked_urlopen.call_args
253+
data = tests.conftest.decode_request_data(call_kwargs["data"])
254+
assert "memoryUsage" not in data
255+
assert "memoryTotal" not in data
114256

115257

116258
@unittest.mock.patch(

0 commit comments

Comments
 (0)