Repository navigation
feat(middleware): Log request payload size #1162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,16 +58,29 @@ async def http_request_logger(request: Request, call_next) -> Response: | |
| return await _log_http_request(request, call_next) | ||
|
|
||
|
|
||
| def _resolve_request_body_size(request: Request) -> int: | ||
| """ | ||
| Read the request payload size from Content-Length. | ||
| Returns 0 when the header is absent or malformed (e.g. chunked transfer). | ||
| """ | ||
| try: | ||
| return int(request.headers.get("content-length") or 0) | ||
| except ValueError: | ||
| return 0 | ||
|
|
||
|
|
||
| async def _log_http_request(request: Request, call_next) -> Response: | ||
| start_time = time.time() | ||
| method = request.method | ||
| raw_path = request.url.path | ||
| request_body_size = _resolve_request_body_size(request) | ||
|
|
||
| span = trace.get_current_span() | ||
| if span.is_recording(): | ||
| span.set_attribute("http.request.method", method) | ||
| span.set_attribute("http.request_method", method) | ||
| span.set_attribute("http.method", method) | ||
| span.set_attribute("http.request.body.size", request_body_size) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kartpop, also adding the request body size as a Sentry attribute, so we can filter requests using the |
||
|
|
||
| if sentry_sdk.get_client().is_active(): | ||
| sentry_sdk.set_tag("http.method", method) | ||
|
|
@@ -102,7 +115,11 @@ async def _log_http_request(request: Request, call_next) -> Response: | |
| span.set_attribute("http.request.duration_ms", round(duration_ms, 2)) | ||
|
|
||
| if raw_path not in SILENT_LOG_PATHS: | ||
| logger.info(f"{method} {raw_path} - {status} [{duration_ms:.2f}ms]") | ||
| logger.info( | ||
| f"[_log_http_request] {method} {raw_path} - {status} [{duration_ms:.2f}ms] " | ||
| f"| request_body_size: {request_body_size}B " | ||
| f"| correlation_id: {correlation_id.get()}" | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| try: | ||
| if sentry_sdk.get_client().is_active(): | ||
|
|
@@ -116,6 +133,12 @@ async def _log_http_request(request: Request, call_next) -> Response: | |
| "http.status_code": str(status), | ||
| } | ||
| sentry_sdk.metrics.count("http.server.request.count", 1, attributes=attrs) | ||
| sentry_sdk.metrics.distribution( | ||
| "http.server.request.body.size", | ||
| request_body_size, | ||
| unit="byte", | ||
| attributes=attrs, | ||
| ) | ||
| sentry_sdk.metrics.distribution( | ||
| "http.server.request.duration", | ||
| duration_ms, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import logging | ||
| import re | ||
|
|
||
| import pytest | ||
| from fastapi import Response | ||
| from fastapi.testclient import TestClient | ||
| from starlette.requests import Request | ||
|
|
||
| from app.core.middleware import _resolve_request_body_size | ||
|
|
||
|
|
||
| def _build_request(headers: dict[str, str] | None = None) -> Request: | ||
| raw_headers = [ | ||
| (key.lower().encode(), value.encode()) for key, value in (headers or {}).items() | ||
| ] | ||
| return Request( | ||
| { | ||
| "type": "http", | ||
| "http_version": "1.1", | ||
| "method": "POST", | ||
| "scheme": "http", | ||
| "server": ("testserver", 80), | ||
| "path": "/api/v1/probe", | ||
| "raw_path": b"/api/v1/probe", | ||
| "query_string": b"", | ||
| "root_path": "", | ||
| "headers": raw_headers, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| class TestResolveRequestBodySize: | ||
| def test_reads_content_length_header(self) -> None: | ||
| request = _build_request({"content-length": "512"}) | ||
| assert _resolve_request_body_size(request) == 512 | ||
|
|
||
| def test_missing_header_is_zero(self) -> None: | ||
| assert _resolve_request_body_size(_build_request()) == 0 | ||
|
|
||
| def test_empty_header_is_zero(self) -> None: | ||
| request = _build_request({"content-length": ""}) | ||
| assert _resolve_request_body_size(request) == 0 | ||
|
|
||
| @pytest.mark.parametrize("value", ["chunked", "12.5", "-"]) | ||
| def test_malformed_header_is_zero(self, value: str) -> None: | ||
| request = _build_request({"content-length": value}) | ||
| assert _resolve_request_body_size(request) == 0 | ||
|
Comment on lines
+44
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- applicable repository conventions ---'
find /tmp/coderabbit-repo-knowledge/projecttech4devai-kaapi-backend-9f364c5e -type f -name '*.md' -print
printf '%s\n' '--- test file ---'
cat -n backend/app/tests/core/test_middleware.py
printf '%s\n' '--- resolver and callers ---'
rg -n -C 8 '_resolve_request_body_size|content-length|Content-Length' backend/app --glob '*.py'Repository: ProjectTech4DevAI/kaapi-backend Length of output: 23688 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- middleware consumer path ---'
cat -n backend/app/core/middleware.py | sed -n '61,125p'
printf '%s\n' '--- test and repository conventions ---'
cat /tmp/coderabbit-repo-knowledge/projecttech4devai-kaapi-backend-9f364c5e/conventions/app-tests.md
cat /tmp/coderabbit-repo-knowledge/projecttech4devai-kaapi-backend-9f364c5e/conventions/repo-wide.mdRepository: ProjectTech4DevAI/kaapi-backend Length of output: 4816 🌐 Web query:
💡 Result: In RFC 9110, the Content-Length header field value is defined by the ABNF production "Content-Length = 1DIGIT" [1]. This grammar explicitly mandates that the value must consist of one or more ASCII digits (0-9) [1]. Because the definition is restricted to "1DIGIT", only non-negative decimal integers are permitted; negative values (such as "-1") or non-numeric characters are invalid because they do not conform to the required ABNF [1]. A sender must not forward a message with an invalid Content-Length value, and a recipient must treat such invalid values as an error [1]. Citations: Test negative numeric
Add 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class TestAccessLogLine: | ||
| def test_logs_body_size_and_correlation_id( | ||
| self, client: TestClient, caplog: pytest.LogCaptureFixture | ||
| ) -> None: | ||
| payload = b"0123456789abcdef" | ||
|
|
||
| with caplog.at_level(logging.INFO, logger="http_request_logger"): | ||
| response = client.post("/api/v1/no-such-route", content=payload) | ||
|
|
||
| assert response.status_code == 404 | ||
| line = next( | ||
| record.getMessage() | ||
| for record in caplog.records | ||
| if record.name == "http_request_logger" | ||
| ) | ||
| assert "POST /api/v1/no-such-route - 404" in line | ||
| assert "| request_body_size: 16B |" in line | ||
| assert re.search(r"correlation_id: [0-9a-f]{32}$", line) | ||
|
|
||
| def test_logs_zero_for_bodyless_request( | ||
| self, client: TestClient, caplog: pytest.LogCaptureFixture | ||
| ) -> None: | ||
| with caplog.at_level(logging.INFO, logger="http_request_logger"): | ||
| client.get("/api/v1/no-such-route") | ||
|
|
||
| line = next( | ||
| record.getMessage() | ||
| for record in caplog.records | ||
| if record.name == "http_request_logger" | ||
| ) | ||
| assert "| request_body_size: 0B |" in line | ||
|
|
||
|
|
||
| class TestBodySizeSpanAttribute: | ||
| @pytest.mark.asyncio | ||
| async def test_recording_span_records_body_size(self) -> None: | ||
| from opentelemetry import trace | ||
| from opentelemetry.sdk.trace import TracerProvider | ||
| from opentelemetry.sdk.trace.export import SimpleSpanProcessor | ||
| from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( | ||
| InMemorySpanExporter, | ||
| ) | ||
|
|
||
| from app.core.middleware import _log_http_request | ||
|
|
||
| exporter = InMemorySpanExporter() | ||
| provider = TracerProvider() | ||
| provider.add_span_processor(SimpleSpanProcessor(exporter)) | ||
| tracer = provider.get_tracer(__name__) | ||
|
|
||
| async def call_next(_: Request) -> Response: | ||
| return Response(status_code=200) | ||
|
|
||
| request = _build_request({"content-length": "2048"}) | ||
| with trace.use_span(tracer.start_span("test-request"), end_on_exit=True): | ||
| await _log_http_request(request, call_next) | ||
|
|
||
| (span,) = exporter.get_finished_spans() | ||
| assert span.attributes is not None | ||
| assert span.attributes["http.request.body.size"] == 2048 | ||
|
|
||
|
|
||
| class TestSentryBodySizeMetric: | ||
| @pytest.mark.asyncio | ||
| async def test_emits_body_size_distribution( | ||
| self, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| import sentry_sdk | ||
|
|
||
| from app.core.middleware import _log_http_request | ||
|
|
||
| distributions: list[tuple[str, float, str | None]] = [] | ||
|
|
||
| class ActiveClient: | ||
| def is_active(self) -> bool: | ||
| return True | ||
|
|
||
| monkeypatch.setattr(sentry_sdk, "get_client", lambda: ActiveClient()) | ||
| monkeypatch.setattr( | ||
| sentry_sdk.metrics, | ||
| "distribution", | ||
| lambda name, value, unit=None, attributes=None: distributions.append( | ||
| (name, value, unit) | ||
| ), | ||
| ) | ||
| monkeypatch.setattr( | ||
| sentry_sdk.metrics, "count", lambda name, value, attributes=None: None | ||
|
Comment on lines
+127
to
+136
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🌐 Web query:
💡 Result: For sentry-sdk version 2.20.0, the callable signatures for sentry_sdk.metrics.count and sentry_sdk.metrics.distribution are identical [1]. Both functions accept a name, a numeric value, and optional unit and attributes parameters [1][2]. The signatures are as follows: sentry_sdk.metrics.count( name: str, value: float, unit: Optional[str] = None, attributes: Optional[dict[str, Any]] = None) -> None sentry_sdk.metrics.distribution( name: str, value: float, unit: Optional[str] = None, attributes: Optional[dict[str, Any]] = None) -> None In these signatures: - name: The identifier for the metric [3][2]. - value: The numeric measurement to record (typically a float) [1][4][2]. - unit: An optional string representing the unit of measurement (e.g., "millisecond", "byte") [1][3][2]. - attributes: An optional dictionary providing additional context (key-value pairs) for the metric [1][4][2]. These methods are defined within the sentry_sdk.metrics module [1]. Note that while the underlying implementation converts values to floats [1], the SDK accepts standard numeric types. Consistent with the Sentry metrics API design, these methods do not return a value [1]. Citations:
🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- target test ---'
sed -n '105,175p' backend/app/tests/core/test_middleware.py
printf '%s\n' '--- dependency declarations mentioning sentry ---'
rg -n -i 'sentry|sentry-sdk' pyproject.toml requirements*.txt setup.cfg setup.py backend 2>/dev/null | head -80
printf '%s\n' '--- scoped repository conventions and learnings ---'
find /tmp/coderabbit-repo-knowledge/projecttech4devai-kaapi-backend-9f364c5e -type f -name '*.md' -maxdepth 3 -printRepository: ProjectTech4DevAI/kaapi-backend Length of output: 12425 🌐 Web query:
💡 Result: In def increment(
key,
value=1.0,
unit="none",
tags=None,
timestamp=None,
stacklevel=0,
):The distribution API is: def distribution(
key,
value,
unit="none",
tags=None,
timestamp=None,
stacklevel=0,
):Neither function accepts a callable directly. Callable/decorator support is provided by Citations: 🌐 Web query:
💡 Result: In def count(
name: str,
value: float,
unit: Optional[str] = None,
attributes: Optional[dict[str, Any]] = None,
) -> Nonedef distribution(
name: str,
value: float,
unit: Optional[str] = None,
attributes: Optional[dict[str, Any]] = None,
) -> NoneBoth are callable as: sentry_sdk.metrics.count(name, value, unit=None, attributes=None)
sentry_sdk.metrics.distribution(name, value, unit=None, attributes=None)[1] (raw.githubusercontent.com) Citations: 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- Sentry metric emission implementation ---'
sed -n '25,62p' backend/app/core/telemetry.py
printf '%s\n' '--- dependency and lock declarations ---'
sed -n '1,35p' backend/pyproject.toml
sed -n '4210,4240p' backend/uv.lockRepository: ProjectTech4DevAI/kaapi-backend Length of output: 5380 Align the Sentry metric test doubles with a supported SDK version.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| ) | ||
|
|
||
| async def call_next(_: Request) -> Response: | ||
| return Response(status_code=200) | ||
|
|
||
| request = _build_request({"content-length": "4096"}) | ||
| await _log_http_request(request, call_next) | ||
|
|
||
| body_size_metrics = [m for m in distributions if m[0].endswith("body.size")] | ||
| assert body_size_metrics == [("http.server.request.body.size", 4096, "byte")] | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_no_metrics_when_sentry_inactive( | ||
| self, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| import sentry_sdk | ||
|
|
||
| from app.core.middleware import _log_http_request | ||
|
|
||
| emitted: list[str] = [] | ||
|
|
||
| class InactiveClient: | ||
| def is_active(self) -> bool: | ||
| return False | ||
|
|
||
| monkeypatch.setattr(sentry_sdk, "get_client", lambda: InactiveClient()) | ||
| monkeypatch.setattr( | ||
| sentry_sdk.metrics, | ||
| "distribution", | ||
| lambda name, *a, **kw: emitted.append(name), | ||
| ) | ||
|
|
||
| async def call_next(_: Request) -> Response: | ||
| return Response(status_code=200) | ||
|
|
||
| await _log_http_request(_build_request({"content-length": "77"}), call_next) | ||
|
|
||
| assert emitted == [] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: ProjectTech4DevAI/kaapi-backend
Length of output: 169
🏁 Script executed:
Repository: ProjectTech4DevAI/kaapi-backend
Length of output: 10551
Reject negative
Content-Lengthvalues._resolve_request_body_sizeconvertsContent-Length: -1to-1. This value reaches the span, access log, and Sentry metrics. Return0when the parsed value is negative.🤖 Prompt for AI Agents