Skip to content

Commit 7b69f19

Browse files
lesnik512claude
andcommitted
refactor(observability): redact url at the _emit_event chokepoint
Move URL redaction from per-emit-site _observed_url() into _emit_event itself, so the single emission boundary sanitizes the `url` attribute for both the log record and the OTel span event. A future emit site can no longer reintroduce the leak by passing a raw url, and the four middleware modules revert to their original `str(request.url)` (net-unchanged vs main). Adds direct _emit_event redaction tests for both channels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eface88 commit 7b69f19

6 files changed

Lines changed: 61 additions & 23 deletions

File tree

src/httpware/_internal/observability.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,10 @@
1212
import logging
1313
import typing
1414

15-
import httpx2
16-
1715
from httpware._internal import import_checker
1816
from httpware._internal.redaction import redact_url
1917

2018

21-
def _observed_url(request: httpx2.Request) -> str:
22-
"""Return the request URL safe for emission (userinfo + sensitive query masked)."""
23-
return redact_url(str(request.url))
24-
25-
2619
def _emit_event(
2720
logger: logging.Logger,
2821
event_name: str,
@@ -33,6 +26,12 @@ def _emit_event(
3326
) -> None:
3427
"""Emit one observability event to both channels.
3528
29+
The ``url`` attribute, when present, is run through
30+
``redaction.redact_url`` here — at the single emission boundary — so a
31+
request URL's userinfo and known-sensitive query/fragment secrets never
32+
reach a log record or span event, regardless of how a caller built the
33+
attributes dict.
34+
3635
1. Always emits a structured log record at ``level`` with ``extra=attributes``
3736
(so log aggregators that index ``extra`` see structured fields).
3837
2. If ``opentelemetry-api`` is installed, calls
@@ -47,7 +46,9 @@ def _emit_event(
4746
the optional-extras isolation invariant: ``import httpware`` must not pull
4847
``opentelemetry`` into ``sys.modules`` when the extra is absent.
4948
"""
50-
logger.log(level, message, extra={**attributes, "event": event_name})
49+
raw_url = attributes.get("url")
50+
safe_attributes = {**attributes, "url": redact_url(raw_url)} if isinstance(raw_url, str) else attributes
51+
logger.log(level, message, extra={**safe_attributes, "event": event_name})
5152
if import_checker.is_otel_installed:
5253
try:
5354
from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
@@ -60,4 +61,4 @@ def _emit_event(
6061
# The structured log record above has already fired; CancelledError/KeyboardInterrupt
6162
# are not Exception subclasses and will still propagate.
6263
with contextlib.suppress(Exception):
63-
trace.get_current_span().add_event(event_name, attributes=attributes)
64+
trace.get_current_span().add_event(event_name, attributes=safe_attributes)

src/httpware/middleware/resilience/bulkhead.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
import httpx2
2626

27-
from httpware._internal.observability import _emit_event, _observed_url
27+
from httpware._internal.observability import _emit_event
2828
from httpware.errors import BulkheadFullError
2929
from httpware.middleware import AsyncNext, Next
3030

@@ -114,7 +114,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
114114
"max_concurrent": self._max_concurrent,
115115
"acquire_timeout": self._acquire_timeout,
116116
"method": request.method,
117-
"url": _observed_url(request),
117+
"url": str(request.url),
118118
},
119119
)
120120
raise BulkheadFullError(
@@ -170,7 +170,7 @@ def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # n
170170
"max_concurrent": self._max_concurrent,
171171
"acquire_timeout": self._acquire_timeout,
172172
"method": request.method,
173-
"url": _observed_url(request),
173+
"url": str(request.url),
174174
},
175175
)
176176
raise BulkheadFullError(

src/httpware/middleware/resilience/circuit_breaker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
import httpx2
3636

37-
from httpware._internal.observability import _emit_event, _observed_url
37+
from httpware._internal.observability import _emit_event
3838
from httpware.errors import CircuitOpenError, NetworkError, StatusError, TimeoutError # noqa: A004
3939
from httpware.middleware import AsyncNext, Next
4040

@@ -190,7 +190,7 @@ def _emit(
190190
event_name,
191191
level=level,
192192
message=message,
193-
attributes={**attributes, "method": request.method, "url": _observed_url(request)},
193+
attributes={**attributes, "method": request.method, "url": str(request.url)},
194194
)
195195

196196

src/httpware/middleware/resilience/retry.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import httpx2
1919

20-
from httpware._internal.observability import _emit_event, _observed_url
20+
from httpware._internal.observability import _emit_event
2121
from httpware._internal.status import STREAMING_BODY_MARKER
2222
from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
2323
from httpware.middleware import AsyncNext, Next
@@ -133,7 +133,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
133133
message="retry refused — request body is a stream that cannot replay",
134134
attributes={
135135
"method": request.method,
136-
"url": _observed_url(request),
136+
"url": str(request.url),
137137
"last_exception_type": type(last_exc).__qualname__,
138138
},
139139
)
@@ -152,7 +152,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
152152
attributes={
153153
"attempts": attempt + 1,
154154
"method": request.method,
155-
"url": _observed_url(request),
155+
"url": str(request.url),
156156
"last_status": last_response.status_code if last_response is not None else None,
157157
"last_exception_type": type(last_exc).__qualname__,
158158
},
@@ -168,7 +168,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
168168
attributes={
169169
"attempts": attempt + 1,
170170
"method": request.method,
171-
"url": _observed_url(request),
171+
"url": str(request.url),
172172
"last_status": last_response.status_code if last_response is not None else None,
173173
},
174174
)
@@ -271,7 +271,7 @@ def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # n
271271
message="retry refused — request body is a stream that cannot replay",
272272
attributes={
273273
"method": request.method,
274-
"url": _observed_url(request),
274+
"url": str(request.url),
275275
"last_exception_type": type(last_exc).__qualname__,
276276
},
277277
)
@@ -290,7 +290,7 @@ def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # n
290290
attributes={
291291
"attempts": attempt + 1,
292292
"method": request.method,
293-
"url": _observed_url(request),
293+
"url": str(request.url),
294294
"last_status": last_response.status_code if last_response is not None else None,
295295
"last_exception_type": type(last_exc).__qualname__,
296296
},
@@ -306,7 +306,7 @@ def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # n
306306
attributes={
307307
"attempts": attempt + 1,
308308
"method": request.method,
309-
"url": _observed_url(request),
309+
"url": str(request.url),
310310
"last_status": last_response.status_code if last_response is not None else None,
311311
},
312312
)

src/httpware/middleware/resilience/timeout.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import httpx2
2020

21-
from httpware._internal.observability import _emit_event, _observed_url
21+
from httpware._internal.observability import _emit_event
2222
from httpware.errors import TimeoutError as HttpwareTimeoutError
2323
from httpware.middleware import AsyncNext
2424

@@ -69,7 +69,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
6969
attributes={
7070
"timeout": self._timeout,
7171
"method": request.method,
72-
"url": _observed_url(request),
72+
"url": str(request.url),
7373
},
7474
)
7575
msg = f"overall timeout of {self._timeout}s exceeded"

tests/test_observability.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,43 @@ def test_emit_event_logs_at_warning_with_extra_fields(caplog: pytest.LogCaptureF
3232
assert record.event == "test.event" # ty: ignore[unresolved-attribute]
3333

3434

35+
def test_emit_event_redacts_url_secret_in_log_record(caplog: pytest.LogCaptureFixture) -> None:
36+
"""The `url` attribute is redacted at the emission boundary, before the log record fires."""
37+
with caplog.at_level(logging.WARNING, logger="httpware.test.observability"):
38+
_emit_event(
39+
_TEST_LOGGER,
40+
"test.event",
41+
level=logging.WARNING,
42+
message="leaky",
43+
attributes={"url": "https://u:p@example.test/x?api_key=topsecret"},
44+
)
45+
46+
record = caplog.records[0]
47+
assert "topsecret" not in record.url # ty: ignore[unresolved-attribute]
48+
assert "api_key=REDACTED" in record.url # ty: ignore[unresolved-attribute]
49+
assert "u:p@" not in record.url # ty: ignore[unresolved-attribute]
50+
51+
52+
def test_emit_event_redacts_url_secret_in_otel_event() -> None:
53+
"""The OTel span event receives the redacted `url`, not the raw secret."""
54+
mock_span = MagicMock(name="MockSpan")
55+
with (
56+
patch("httpware._internal.import_checker.is_otel_installed", True),
57+
patch("opentelemetry.trace.get_current_span", return_value=mock_span),
58+
):
59+
_emit_event(
60+
_TEST_LOGGER,
61+
"test.event",
62+
level=logging.WARNING,
63+
message="leaky",
64+
attributes={"url": "https://example.test/x?token=topsecret"},
65+
)
66+
67+
_, kwargs = mock_span.add_event.call_args
68+
assert "topsecret" not in kwargs["attributes"]["url"]
69+
assert "token=REDACTED" in kwargs["attributes"]["url"]
70+
71+
3572
def test_emit_event_respects_level_parameter(caplog: pytest.LogCaptureFixture) -> None:
3673
"""When level=DEBUG is passed, the record is at DEBUG."""
3774
with caplog.at_level(logging.DEBUG, logger="httpware.test.observability"):

0 commit comments

Comments
 (0)