Skip to content

Commit d829ad7

Browse files
olivermeyerclaude
andcommitted
fix(log): silence httpx and urllib3 request logs
logging_initialize sets the stdlib loggers httpx, httpx2 and urllib3 to WARNING. httpx logs each request URL at INFO, including the query string of signed URLs. The root logger is at level 0, so these lines got to all loguru sinks and to Sentry breadcrumbs. Tests restore the stdlib logger levels, the root level and the InterceptHandler after each logging_initialize call. BREAKING CHANGE: logging_initialize sets the httpx, httpx2 and urllib3 loggers to WARNING. INFO request lines no longer get to the log sinks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8055f9c commit d829ad7

5 files changed

Lines changed: 119 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ Settings class: `LogSettings`
144144
| `{PREFIX}LOG_FILE_NAME` | platform log dir | Path to the log file (validated on startup when `FILE_ENABLED` is true). |
145145
| `{PREFIX}LOG_REDIRECT_LOGGING` | `true` | Redirect stdlib `logging` to loguru via `InterceptHandler`. |
146146

147+
`logging_initialize()` sets the stdlib loggers `httpx`, `httpx2` and `urllib3` to `WARNING`. These
148+
HTTP clients log each request URL at `INFO`, and the URL can contain credentials in its query string
149+
(for example, the signature of a signed URL). At `WARNING`, these request lines do not get to the log
150+
sinks or to Sentry. The `psycopg` and `psycopg.pool` loggers are also at `WARNING`.
151+
147152
#### Sentry (`{PREFIX}SENTRY_`)
148153

149154
Settings class: `SentrySettings`. Sentry is only initialised when `ENABLED=true` **and** `DSN` is

‎src/aignostics_foundry_core/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
171171
- **Key Features**:
172172
- `InterceptHandler(logging.Handler)` — redirects stdlib log records to loguru, preserving original module/function/line metadata
173173
- `LogSettings(BaseSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}LOG_`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging`
174-
- `logging_initialize(filter_func, *, context)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; reads project name, version, and env file list from `context` (falls back to process-level context); embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; suppresses psycopg pool noise
174+
- `logging_initialize(filter_func, *, context)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; reads project name, version, and env file list from `context` (falls back to process-level context); embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; sets the stdlib loggers `psycopg`, `psycopg.pool`, `httpx`, `httpx2` and `urllib3` to WARNING (process-wide), so the INFO request lines of the HTTP clients, which contain the request URL and its query string, do not get to the sinks
175175
- **Location**: `aignostics_foundry_core/log.py`
176176
- **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory)
177177
- **Import**: `from aignostics_foundry_core.log import logging_initialize, LogSettings, InterceptHandler`

‎src/aignostics_foundry_core/log.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
- psycopg.pool: Set to WARNING level to suppress verbose INFO logs from
88
connection pool operations (getconn/putconn). Only non-nominal pool
99
messages (warnings and errors) are logged.
10+
- httpx, httpx2 and urllib3: Set to WARNING level. These HTTP clients log each
11+
request URL at INFO, and the URL can include the query string of a signed URL.
12+
At WARNING, these request lines do not get to the log sinks or to Sentry.
1013
"""
1114

1215
import contextlib
@@ -31,6 +34,7 @@
3134
from aignostics_foundry_core.foundry import FoundryContext
3235

3336
_DEFAULT_PROJECT = "foundry"
37+
_HTTP_CLIENT_LOGGERS = ("httpx", "httpx2", "urllib3")
3438

3539

3640
def _validate_file_name(file_name: str | None) -> str | None:
@@ -186,6 +190,9 @@ def logging_initialize(
186190
handlers based on settings read from environment variables with the
187191
``{ctx.env_prefix}LOG_`` prefix (derived from the context).
188192
193+
Sets the stdlib loggers ``psycopg``, ``psycopg.pool``, ``httpx``, ``httpx2``
194+
and ``urllib3`` to WARNING. These levels are process-wide.
195+
189196
Args:
190197
filter_func: Optional loguru filter callable; receives a ``Record``
191198
and returns ``True`` to keep the message, ``False`` to drop it.
@@ -234,4 +241,10 @@ def logging_initialize(
234241
logging.getLogger("psycopg.pool").setLevel(logging.WARNING)
235242
logger.trace("psycopg and psycopg.pool loggers set to WARNING to prevent deadlock and reduce noise")
236243

244+
# Suppress the INFO request lines of the HTTP clients. Each line contains the full request URL,
245+
# which can include credentials in the query string (for example, the signature of a signed URL).
246+
for http_client_logger in _HTTP_CLIENT_LOGGERS:
247+
logging.getLogger(http_client_logger).setLevel(logging.WARNING)
248+
logger.trace("{} loggers set to WARNING to keep request URLs out of the logs", ", ".join(_HTTP_CLIENT_LOGGERS))
249+
237250
logger.trace("Logging initialized with level: {}", settings.level)

‎tests/aignostics_foundry_core/conftest.py‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import logging
66
import os
7+
from contextlib import contextmanager
78
from typing import TYPE_CHECKING
89
from unittest.mock import MagicMock, patch
910

@@ -15,7 +16,7 @@
1516
from tests.conftest import TEST_PROJECT_PREFIX
1617

1718
if TYPE_CHECKING:
18-
from collections.abc import Generator
19+
from collections.abc import Generator, Iterator
1920

2021
from opentelemetry.sdk._logs import LoggerProvider # pyright: ignore[reportPrivateImportUsage]
2122

@@ -25,6 +26,45 @@
2526
# otel_initialize() writes these with os.environ.setdefault(), so monkeypatch cannot undo them.
2627
_OTEL_SDK_ENV_DEFAULTS = ("OTEL_SERVICE_NAME", "OTEL_SEMCONV_STABILITY_OPT_IN", "OTEL_EXPORTER_OTLP_CERTIFICATE")
2728

29+
# logging_initialize() sets the level of these stdlib loggers for the whole process.
30+
_LOGGERS_SET_BY_LOGGING_INITIALIZE = ("psycopg", "psycopg.pool", "httpx", "httpx2", "urllib3")
31+
32+
33+
@contextmanager
34+
def stdlib_logging_restored() -> Iterator[None]:
35+
"""Undo the process-wide logging changes of ``logging_initialize`` on exit.
36+
37+
On exit, removes all loguru sinks and the ``InterceptHandler`` on the stdlib
38+
root logger. Restores the level of the root logger and of the loggers that
39+
``logging_initialize`` sets to WARNING.
40+
41+
Yields:
42+
None
43+
"""
44+
root_logger = logging.getLogger()
45+
saved_root_level = root_logger.level
46+
saved_levels = {name: logging.getLogger(name).level for name in _LOGGERS_SET_BY_LOGGING_INITIALIZE}
47+
try:
48+
yield
49+
finally:
50+
logger.remove()
51+
for handler in [h for h in root_logger.handlers if isinstance(h, InterceptHandler)]:
52+
root_logger.removeHandler(handler)
53+
root_logger.setLevel(saved_root_level)
54+
for name, level in saved_levels.items():
55+
logging.getLogger(name).setLevel(level)
56+
57+
58+
@pytest.fixture
59+
def stdlib_logging_reset() -> Generator[None, None, None]:
60+
"""Undo the process-wide logging changes of ``logging_initialize`` after the test.
61+
62+
Yields:
63+
None
64+
"""
65+
with stdlib_logging_restored():
66+
yield
67+
2868

2969
class OtlpLogCapture:
3070
"""Access to the log records that the OTLP log sink exports."""
@@ -63,9 +103,9 @@ def otlp_log_exporter(monkeypatch: pytest.MonkeyPatch) -> Generator[OtlpLogCaptu
63103
patches only OpenTelemetry symbols, so the global ``LoggerProvider`` stays
64104
unset.
65105
66-
Teardown shuts the ``LoggerProvider`` down, removes all loguru sinks, removes
67-
the ``InterceptHandler`` that ``logging_initialize`` puts on the stdlib root
68-
logger, and restores the root logger level and the ``OTEL_*`` defaults.
106+
Teardown shuts the ``LoggerProvider`` down, undoes the process-wide logging
107+
changes of ``logging_initialize`` (see :func:`stdlib_logging_restored`), and
108+
restores the ``OTEL_*`` defaults.
69109
70110
Yields:
71111
OtlpLogCapture: Access to the exported log records.
@@ -76,11 +116,10 @@ def otlp_log_exporter(monkeypatch: pytest.MonkeyPatch) -> Generator[OtlpLogCaptu
76116
monkeypatch.setenv(f"{_OTEL_PREFIX}METRICS_ENABLED", "false")
77117
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _OTLP_ENDPOINT)
78118
saved_env = {name: os.environ.get(name) for name in _OTEL_SDK_ENV_DEFAULTS}
79-
root_logger = logging.getLogger()
80-
saved_root_level = root_logger.level
81119

82120
exporter = InMemoryLogRecordExporter()
83121
with (
122+
stdlib_logging_restored(),
84123
patch("opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter", return_value=exporter),
85124
patch("opentelemetry._logs.set_logger_provider") as set_logger_provider,
86125
):
@@ -90,10 +129,6 @@ def otlp_log_exporter(monkeypatch: pytest.MonkeyPatch) -> Generator[OtlpLogCaptu
90129
finally:
91130
if capture.provider is not None:
92131
capture.provider.shutdown()
93-
logger.remove()
94-
for handler in [h for h in root_logger.handlers if isinstance(h, InterceptHandler)]:
95-
root_logger.removeHandler(handler)
96-
root_logger.setLevel(saved_root_level)
97132
for name, value in saved_env.items():
98133
if value is None:
99134
os.environ.pop(name, None)

‎tests/aignostics_foundry_core/log_test.py‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@
22

33
import logging as stdlib_logging
44
import sys
5+
from contextlib import contextmanager
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Any
78

9+
import httpx
810
import pytest
11+
from loguru import logger
912
from pydantic import ValidationError
1013

1114
from aignostics_foundry_core.log import InterceptHandler, LogSettings, logging_initialize
1215
from tests.conftest import TEST_PROJECT_PREFIX
1316

1417
if TYPE_CHECKING:
15-
from loguru import Message
18+
from collections.abc import Iterator
19+
20+
from loguru import Message, Record
1621

1722
_MARKER_MESSAGE = "log_test_unique_marker_4f2a"
1823
_STDLIB_MESSAGE = "stdlib_redirect_unique_marker_9b3c"
@@ -24,10 +29,34 @@
2429
_CONTEXTUALIZE_MARKER = "contextualize_intercept_marker_6d2e"
2530
_PARITY_NATIVE_MARKER = "parity_native_loguru_4b7a"
2631
_PARITY_STDLIB_MARKER = "parity_stdlib_intercept_9c1f"
32+
_HTTP_CLIENT_INFO_MARKER = "http_client_info_marker_5a8d"
33+
_HTTP_CLIENT_WARNING_MARKER = "http_client_warning_marker_2c6b"
34+
_SIGNED_URL = "https://example.com/blob?sig=secret"
35+
_SIGNED_URL_QUERY = "sig=secret"
36+
37+
38+
@contextmanager
39+
def _captured_records() -> "Iterator[list[Record]]":
40+
"""Collect every loguru record at TRACE and above while the block runs.
41+
42+
Yields:
43+
list[Record]: The records that loguru emitted inside the block.
44+
"""
45+
records: list[Record] = []
46+
47+
def capture(message: "Message") -> None:
48+
records.append(message.record)
49+
50+
sink_id = logger.add(capture, level="TRACE")
51+
try:
52+
yield records
53+
finally:
54+
logger.remove(sink_id)
2755

2856

2957
@pytest.mark.sequential
3058
@pytest.mark.unit
59+
@pytest.mark.usefixtures("stdlib_logging_reset")
3160
class TestLoggingInitialize:
3261
"""Behavioural tests for logging_initialize()."""
3362

@@ -103,6 +132,31 @@ def test_logging_initialize_suppresses_psycopg_loggers(self) -> None:
103132
assert stdlib_logging.getLogger("psycopg").level == stdlib_logging.WARNING
104133
assert stdlib_logging.getLogger("psycopg.pool").level == stdlib_logging.WARNING
105134

135+
@pytest.mark.parametrize("logger_name", ["httpx", "httpx2", "urllib3"])
136+
def test_logging_initialize_sets_http_client_loggers_to_warning(self, logger_name: str) -> None:
137+
"""After logging_initialize(), the HTTP client loggers pass WARNING records but drop INFO records."""
138+
logging_initialize()
139+
140+
with _captured_records() as records:
141+
stdlib_logging.getLogger(logger_name).info(_HTTP_CLIENT_INFO_MARKER)
142+
stdlib_logging.getLogger(logger_name).warning(_HTTP_CLIENT_WARNING_MARKER)
143+
144+
assert [(r["level"].name, r["message"]) for r in records if r["name"] == logger_name] == [
145+
("WARNING", _HTTP_CLIENT_WARNING_MARKER)
146+
]
147+
148+
def test_logging_initialize_drops_httpx_request_line(self) -> None:
149+
"""An httpx request writes no record, so the query string of a signed URL gets to no sink."""
150+
logging_initialize()
151+
transport = httpx.MockTransport(lambda _request: httpx.Response(200))
152+
153+
with _captured_records() as records, httpx.Client(transport=transport) as client:
154+
response = client.get(_SIGNED_URL)
155+
156+
assert response.status_code == 200
157+
assert [r["name"] for r in records if (r["name"] or "").startswith("httpx")] == []
158+
assert all(_SIGNED_URL_QUERY not in r["message"] for r in records)
159+
106160
def test_logging_initialize_uses_context_project_name(
107161
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
108162
) -> None:

0 commit comments

Comments
 (0)