Skip to content

Commit 448b150

Browse files
author
Samson Gebre
committed
feat: enhance HTTP logging with precise filename timestamps and improved body logging
1 parent 0569eb5 commit 448b150

4 files changed

Lines changed: 166 additions & 20 deletions

File tree

src/PowerPlatform/Dataverse/core/_http.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,20 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
8181
m = (method or "").lower()
8282
kwargs["timeout"] = 120 if m in ("post", "delete") else 10
8383

84-
# Log outbound request once (before retry loop)
84+
# Log outbound request once (before retry loop).
85+
# Use explicit key presence checks so falsy values (e.g. {}) are logged correctly.
8586
if self._logger is not None:
87+
if "json" in kwargs:
88+
req_body = kwargs["json"]
89+
elif "data" in kwargs:
90+
req_body = kwargs["data"]
91+
else:
92+
req_body = None
8693
self._logger.log_request(
8794
method,
8895
url,
8996
headers=kwargs.get("headers"),
90-
body=kwargs.get("json") or kwargs.get("data"),
97+
body=req_body,
9198
)
9299

93100
# Small backoff retry on network errors only
@@ -99,12 +106,19 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
99106
elapsed_ms = (time.monotonic() - t0) * 1000
100107

101108
if self._logger is not None:
109+
# Only decode resp.text when body logging is enabled — avoids
110+
# unnecessary overhead for large payloads when max_body_bytes == 0.
111+
resp_body = (
112+
resp.text
113+
if self._logger._config.max_body_bytes != 0
114+
else None
115+
)
102116
self._logger.log_response(
103117
method,
104118
url,
105119
status_code=resp.status_code,
106120
headers=dict(resp.headers),
107-
body=resp.text,
121+
body=resp_body,
108122
elapsed_ms=elapsed_ms,
109123
)
110124
return resp

src/PowerPlatform/Dataverse/core/_http_logger.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ def __init__(self, config: LogConfig) -> None:
2828
# Ensure folder exists
2929
os.makedirs(config.log_folder, exist_ok=True)
3030

31-
# Build timestamped filename
32-
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
31+
# Build timestamped filename — microsecond precision avoids collisions when
32+
# multiple clients are created within the same second (e.g. in tests).
33+
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
3334
filename = f"{config.log_file_prefix}_{ts}.log"
3435
filepath = os.path.join(config.log_folder, filename)
3536

@@ -39,7 +40,7 @@ def __init__(self, config: LogConfig) -> None:
3940
self._logger.setLevel(getattr(logging, config.log_level.upper(), logging.DEBUG))
4041
self._logger.propagate = False # don't bubble to root
4142

42-
handler = RotatingFileHandler(
43+
self._handler = RotatingFileHandler(
4344
filepath,
4445
maxBytes=config.max_file_bytes,
4546
backupCount=config.backup_count,
@@ -49,8 +50,8 @@ def __init__(self, config: LogConfig) -> None:
4950
"[%(asctime)s] %(levelname)s %(message)s",
5051
datefmt="%Y-%m-%dT%H:%M:%S%z",
5152
)
52-
handler.setFormatter(formatter)
53-
self._logger.addHandler(handler)
53+
self._handler.setFormatter(formatter)
54+
self._logger.addHandler(self._handler)
5455

5556
def log_request(
5657
self,
@@ -95,6 +96,12 @@ def log_error(self, method: str, url: str, error: Exception) -> None:
9596
"""Log an HTTP transport error."""
9697
self._logger.error(f"!!! ERROR {method.upper()} {url} - {type(error).__name__}: {error}")
9798

99+
def close(self) -> None:
100+
"""Flush and close the underlying file handler. Safe to call multiple times."""
101+
self._handler.flush()
102+
self._handler.close()
103+
self._logger.removeHandler(self._handler)
104+
98105
def _redact_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
99106
return {k: ("[REDACTED]" if k.lower() in self._redacted else v) for k, v in headers.items()}
100107

@@ -114,6 +121,10 @@ def _truncate_body(self, body: Any) -> str:
114121
limit = self._config.max_body_bytes
115122
if limit == 0:
116123
return ""
117-
if len(text) > limit:
118-
return text[:limit] + f"... [truncated, {len(text)} bytes total]"
124+
encoded = text.encode("utf-8")
125+
if len(encoded) > limit:
126+
# Truncate on byte boundary, then decode safely to avoid splitting
127+
# multi-byte characters. Report the true byte length, not char count.
128+
truncated = encoded[:limit].decode("utf-8", errors="ignore")
129+
return truncated + f"... [truncated, {len(encoded)} bytes total]"
119130
return text

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,17 @@ def __init__(
143143
"PowerPlatform.Dataverse.core.config", fromlist=["DataverseConfig"]
144144
).DataverseConfig.from_env()
145145
)
146-
_log = None
146+
self._http_logger = None
147147
if self.config.log_config is not None:
148148
from ..core._http_logger import _HttpLogger
149149

150-
_log = _HttpLogger(self.config.log_config)
150+
self._http_logger = _HttpLogger(self.config.log_config)
151151
self._http = _HttpClient(
152152
retries=self.config.http_retries,
153153
backoff=self.config.http_backoff,
154154
timeout=self.config.http_timeout,
155155
session=session,
156-
logger=_log,
156+
logger=self._http_logger,
157157
)
158158
self._logical_to_entityset_cache: dict[str, str] = {}
159159
# Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid)
@@ -183,6 +183,9 @@ def close(self) -> None:
183183
self._picklist_label_cache.clear()
184184
if self._http is not None:
185185
self._http.close()
186+
if self._http_logger is not None:
187+
self._http_logger.close()
188+
self._http_logger = None
186189

187190
def _headers(self) -> Dict[str, str]:
188191
"""Build standard OData headers with bearer auth."""

tests/unit/core/test_http_logger.py

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55

66
from __future__ import annotations
77

8-
import logging
8+
import dataclasses
99
import os
1010
import re
11-
import tempfile
1211

1312
import pytest
1413

@@ -56,7 +55,7 @@ def test_log_config_defaults():
5655

5756
def test_log_config_frozen():
5857
cfg = LogConfig()
59-
with pytest.raises(Exception): # frozen dataclass raises FrozenInstanceError
58+
with pytest.raises(dataclasses.FrozenInstanceError):
6059
cfg.log_folder = "/other" # type: ignore[misc]
6160

6261

@@ -69,8 +68,8 @@ def test_log_file_created(tmp_path):
6968
_make_logger(tmp_path)
7069
log_files = [f for f in os.listdir(tmp_path) if f.endswith(".log")]
7170
assert len(log_files) == 1
72-
# File should match: <prefix>_YYYYMMDD_HHMMSS.log
73-
assert re.match(r"dataverse_\d{8}_\d{6}\.log", log_files[0])
71+
# File should match: <prefix>_YYYYMMDD_HHMMSS_microseconds.log
72+
assert re.match(r"dataverse_\d{8}_\d{6}_\d+\.log", log_files[0])
7473

7574

7675
def test_log_file_custom_prefix(tmp_path):
@@ -284,8 +283,8 @@ def test_http_client_with_logger_logs_request_and_response(tmp_path):
284283
# ---------------------------------------------------------------------------
285284

286285

287-
def test_dataverse_config_log_config_field():
288-
cfg = LogConfig(log_folder="/tmp/test_logs")
286+
def test_dataverse_config_log_config_field(tmp_path):
287+
cfg = LogConfig(log_folder=str(tmp_path))
289288
dc = DataverseConfig(log_config=cfg)
290289
assert dc.log_config is cfg
291290

@@ -298,3 +297,122 @@ def test_dataverse_config_log_config_default_is_none():
298297
def test_dataverse_config_from_env_log_config_none():
299298
dc = DataverseConfig.from_env()
300299
assert dc.log_config is None
300+
301+
302+
# ---------------------------------------------------------------------------
303+
# Fix #2: empty dict body must be logged, not silently dropped
304+
# ---------------------------------------------------------------------------
305+
306+
307+
def test_http_client_logs_empty_dict_body(tmp_path):
308+
"""An empty JSON body {} is falsy but must still be logged (not skipped via `or`)."""
309+
from unittest.mock import MagicMock
310+
311+
from PowerPlatform.Dataverse.core._http import _HttpClient
312+
313+
mock_resp = MagicMock()
314+
mock_resp.status_code = 200
315+
mock_resp.headers = {}
316+
mock_resp.text = ""
317+
318+
session = MagicMock()
319+
session.request.return_value = mock_resp
320+
321+
cfg = LogConfig(log_folder=str(tmp_path))
322+
http_logger = _HttpLogger(cfg)
323+
client = _HttpClient(session=session, logger=http_logger)
324+
client._request("POST", "https://example.com/accounts", json={})
325+
326+
content = _read_log(tmp_path)
327+
assert ">>> REQUEST" in content
328+
assert "{}" in content
329+
330+
331+
# ---------------------------------------------------------------------------
332+
# Fix #3: resp.text not decoded when body logging disabled
333+
# ---------------------------------------------------------------------------
334+
335+
336+
def test_http_client_does_not_decode_response_body_when_logging_disabled(tmp_path):
337+
"""When max_body_bytes=0, resp.text must not be accessed (no unnecessary decoding)."""
338+
from unittest.mock import MagicMock, PropertyMock
339+
340+
from PowerPlatform.Dataverse.core._http import _HttpClient
341+
342+
mock_resp = MagicMock()
343+
mock_resp.status_code = 200
344+
mock_resp.headers = {}
345+
# If resp.text is accessed, the test will fail
346+
type(mock_resp).text = PropertyMock(side_effect=AssertionError("resp.text should not be accessed"))
347+
348+
session = MagicMock()
349+
session.request.return_value = mock_resp
350+
351+
cfg = LogConfig(log_folder=str(tmp_path), max_body_bytes=0)
352+
http_logger = _HttpLogger(cfg)
353+
client = _HttpClient(session=session, logger=http_logger)
354+
# Should not raise
355+
client._request("GET", "https://example.com")
356+
357+
358+
# ---------------------------------------------------------------------------
359+
# Fix #4: _HttpLogger.close() releases file handle
360+
# ---------------------------------------------------------------------------
361+
362+
363+
def test_http_logger_close_releases_handler(tmp_path):
364+
"""close() flushes and removes the handler so the file handle is released."""
365+
logger = _make_logger(tmp_path)
366+
logger.log_request("GET", "https://example.com")
367+
logger.close()
368+
# After close the internal logger should have no handlers
369+
assert len(logger._logger.handlers) == 0
370+
371+
372+
def test_http_logger_close_is_idempotent(tmp_path):
373+
"""Calling close() twice must not raise."""
374+
logger = _make_logger(tmp_path)
375+
logger.close()
376+
logger.close() # should not raise
377+
378+
379+
# ---------------------------------------------------------------------------
380+
# Fix #5: filename uses microsecond precision (no collision)
381+
# ---------------------------------------------------------------------------
382+
383+
384+
def test_log_filenames_unique_for_rapid_creation(tmp_path):
385+
"""Two loggers created back-to-back get distinct filenames."""
386+
l1 = _make_logger(tmp_path)
387+
l2 = _make_logger(tmp_path)
388+
log_files = [f for f in os.listdir(tmp_path) if f.endswith(".log")]
389+
l1.close()
390+
l2.close()
391+
assert len(log_files) == 2
392+
assert log_files[0] != log_files[1]
393+
394+
395+
# ---------------------------------------------------------------------------
396+
# Fix #6: byte-correct truncation for Unicode bodies
397+
# ---------------------------------------------------------------------------
398+
399+
400+
def test_body_truncation_unicode_byte_accurate(tmp_path):
401+
"""Truncation respects byte budget even for multi-byte Unicode characters."""
402+
# Each '€' is 3 UTF-8 bytes; 10 bytes limit should cut within a few chars
403+
logger = _make_logger(tmp_path, max_body_bytes=10)
404+
body = "€" * 20 # 60 bytes total
405+
logger.log_request("POST", "https://example.com", body=body)
406+
content = _read_log(tmp_path)
407+
assert "truncated" in content
408+
assert "60 bytes total" in content
409+
410+
411+
def test_body_truncation_reports_byte_count_not_char_count(tmp_path):
412+
"""The truncation message reports UTF-8 byte length, not character count."""
413+
# 5 chars × 3 bytes each = 15 bytes; limit 5 bytes → should report 15 bytes
414+
logger = _make_logger(tmp_path, max_body_bytes=5)
415+
body = "€€€€€" # 5 chars, 15 bytes
416+
logger.log_request("POST", "https://example.com", body=body)
417+
content = _read_log(tmp_path)
418+
assert "15 bytes total" in content

0 commit comments

Comments
 (0)