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

Commit 5c29d99

Browse files
committed
Send user's IP address with metrics
Used for visualization aggregate geolocation data. The IP is never stored, and it is never sent to 3rd parties. We read the IP from the `X-Forwarded-For` header since in production one's backend service is most likely behind a reverse proxy of some sort. We use the left-most value in case there are multiple IPs in it, since that's most likely not from "our" infra and we don't have to worry about spoofing too much because it's not used for anything security related. Some additional reading about getting the real IP address: https://adam-p.ca/blog/2022/03/x-forwarded-for/
1 parent 0078fe8 commit 5c29d99

7 files changed

Lines changed: 54 additions & 0 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Send user's IP address with metrics. Used for visualization aggregate geolocation data.
13+
The IP is never stored, and it is never sent to 3rd parties.
14+
1015
## [1.4.0] - 2022-02-20
1116

1217
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def my_apilytics_middleware(request, get_response):
8282
method=request.method,
8383
request_size=len(request.body),
8484
user_agent=request.headers.get("user-agent"),
85+
ip=request.headers.get("x-forwarded-for", "").split(",")[0].strip(),
8586
) as sender:
8687
response = get_response(request)
8788
sender.set_response_info(

apilytics/core.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class ApilyticsSender:
3333
method=request.method,
3434
request_size=len(request.body),
3535
user_agent=request.headers.get("user-agent"),
36+
ip=request.headers.get("x-forwarded-for", "").split(",")[0].strip(),
3637
) as sender:
3738
response = get_response(request)
3839
sender.set_response_info(
@@ -56,6 +57,7 @@ def __init__(
5657
query: Optional[str] = None,
5758
request_size: Optional[int] = None,
5859
user_agent: Optional[str] = None,
60+
ip: Optional[str] = None,
5961
apilytics_integration: Optional[str] = None,
6062
integrated_library: Optional[str] = None,
6163
) -> None:
@@ -71,6 +73,8 @@ def __init__(
7173
request_size: Size of the user's HTTP request's body in bytes.
7274
user_agent: Value of the `User-Agent` header from the user's HTTP request.
7375
An empty string and None are treated equally.
76+
ip: User's IP address (used for geolocation, never stored nor sent to 3rd parties).
77+
An empty string and None are treated equally.
7478
apilytics_integration: Name of the Apilytics integration that's calling this,
7579
e.g. "apilytics-python-django". No need to pass this when calling from user code.
7680
integrated_library: Name and version of the integration that this is used in,
@@ -82,6 +86,7 @@ def __init__(
8286
self._query = query
8387
self._request_size = request_size
8488
self._user_agent = user_agent
89+
self._ip = ip
8590

8691
self._response_size: Optional[int] = None
8792
self._status_code: Optional[int] = None
@@ -148,6 +153,7 @@ def _send_metrics(self) -> None:
148153
# Don't send empty strings.
149154
**({"query": self._query} if self._query else {}),
150155
**({"userAgent": self._user_agent} if self._user_agent else {}),
156+
**({"ip": self._ip} if self._ip else {}),
151157
**(
152158
{"statusCode": self._status_code}
153159
if self._status_code is not None

apilytics/django.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def __call__(self, request: django.http.HttpRequest) -> django.http.HttpResponse
4949
method=request.method or "", # Typed as Optional, should never be None.
5050
request_size=int(request.META.get("CONTENT_LENGTH", 0)),
5151
user_agent=request.headers.get("user-agent"),
52+
ip=request.headers.get("x-forwarded-for", "").split(",")[0].strip(),
5253
apilytics_integration="apilytics-python-django",
5354
integrated_library=f"django/{django.__version__}",
5455
) as sender:

apilytics/fastapi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async def dispatch(
5757
method=request.method,
5858
request_size=len(await request.body()),
5959
user_agent=request.headers.get("user-agent"),
60+
ip=request.headers.get("x-forwarded-for", "").split(",")[0].strip(),
6061
apilytics_integration="apilytics-python-fastapi",
6162
integrated_library=f"fastapi/{fastapi.__version__}",
6263
) as sender:

tests/django/test_django.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,26 @@ def test_middleware_should_send_user_agent(
8787
assert data["userAgent"] == "some agent"
8888

8989

90+
def test_middleware_should_send_ip(
91+
mocked_urlopen: unittest.mock.MagicMock, client: django.test.client.Client
92+
) -> None:
93+
response = client.get("/dummy", HTTP_X_FORWARDED_FOR="127.0.0.1")
94+
assert response.status_code == 200
95+
96+
assert mocked_urlopen.call_count == 1
97+
__, call_kwargs = mocked_urlopen.call_args
98+
data = tests.conftest.decode_request_data(call_kwargs["data"])
99+
assert data["ip"] == "127.0.0.1"
100+
101+
response = client.get("/dummy", HTTP_X_FORWARDED_FOR="127.0.0.2,127.0.0.3")
102+
assert response.status_code == 200
103+
104+
assert mocked_urlopen.call_count == 2
105+
__, call_kwargs = mocked_urlopen.call_args
106+
data = tests.conftest.decode_request_data(call_kwargs["data"])
107+
assert data["ip"] == "127.0.0.2"
108+
109+
90110
def test_middleware_should_handle_zero_request_and_response_sizes(
91111
mocked_urlopen: unittest.mock.MagicMock, client: django.test.client.Client
92112
) -> None:

tests/fastapi/test_fastapi.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@ def test_middleware_should_send_user_agent(
9494
assert data["userAgent"] == "some agent"
9595

9696

97+
def test_middleware_should_send_ip(
98+
mocked_urlopen: unittest.mock.MagicMock,
99+
) -> None:
100+
response = client.get("/dummy", headers={"X-Forwarded-For": "127.0.0.1"})
101+
assert response.status_code == 200
102+
103+
assert mocked_urlopen.call_count == 1
104+
__, call_kwargs = mocked_urlopen.call_args
105+
data = tests.conftest.decode_request_data(call_kwargs["data"])
106+
assert data["ip"] == "127.0.0.1"
107+
108+
response = client.get("/dummy", headers={"X-Forwarded-For": "127.0.0.2,127.0.0.3"})
109+
assert response.status_code == 200
110+
111+
assert mocked_urlopen.call_count == 2
112+
__, call_kwargs = mocked_urlopen.call_args
113+
data = tests.conftest.decode_request_data(call_kwargs["data"])
114+
assert data["ip"] == "127.0.0.2"
115+
116+
97117
def test_middleware_should_handle_zero_request_and_response_sizes(
98118
mocked_urlopen: unittest.mock.MagicMock,
99119
) -> None:

0 commit comments

Comments
 (0)