Skip to content

Commit 6390773

Browse files
committed
feat(spp_dci_client): log outgoing DCI requests to audit trail
Instrument _make_request to log all outgoing calls via spp.api.outgoing.log (soft dependency). Logs persist in a separate cursor so they survive transaction rollback on UserError. Captures timing, status codes, request/response payloads, and error details for success, HTTP errors, connection errors, timeouts, and 401 retries.
1 parent a341e11 commit 6390773

3 files changed

Lines changed: 565 additions & 0 deletions

File tree

spp_dci_client/services/client.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""DCI Client Service for making signed API requests."""
22

33
import logging
4+
import time
45
import uuid
56
from datetime import UTC, datetime
67
from typing import Any
@@ -957,6 +958,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
957958
# Get headers from data source (includes auth)
958959
headers = self.data_source.get_headers()
959960

961+
# Track timing and result for outgoing log
962+
start_time = time.monotonic()
963+
log_status = "success"
964+
log_status_code = None
965+
log_response_data = None
966+
log_error_detail = None
967+
960968
try:
961969
_logger.info(
962970
"Making DCI request to %s (action: %s)",
@@ -983,6 +991,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
983991
# Handle 401 Unauthorized - try refreshing token once
984992
if response.status_code == 401 and _retry_auth and self.data_source.auth_type == "oauth2":
985993
_logger.warning("Got 401 Unauthorized, clearing OAuth2 token cache and retrying with fresh token")
994+
log_status = "http_error"
995+
log_status_code = 401
996+
log_error_detail = "401 Unauthorized - retrying with fresh token"
997+
try:
998+
log_response_data = response.json()
999+
except Exception:
1000+
log_response_data = None
9861001
self.data_source.clear_oauth2_token_cache()
9871002
return self._make_request(endpoint, envelope, _retry_auth=False)
9881003

@@ -991,6 +1006,8 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
9911006

9921007
# Parse response
9931008
response_data = response.json()
1009+
log_status_code = response.status_code
1010+
log_response_data = response_data
9941011

9951012
_logger.info(
9961013
"DCI request successful (status: %s, message_id: %s)",
@@ -1002,18 +1019,23 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
10021019
return response_data
10031020

10041021
except httpx.HTTPStatusError as e:
1022+
log_status = "http_error"
1023+
log_status_code = e.response.status_code
1024+
10051025
# Log technical details for troubleshooting
10061026
technical_detail = f"DCI request failed with status {e.response.status_code}"
10071027
response_text = e.response.text
10081028
try:
10091029
error_data = e.response.json()
1030+
log_response_data = error_data
10101031
if "header" in error_data and "status_reason_message" in error_data["header"]:
10111032
technical_detail += f": {error_data['header']['status_reason_message']}"
10121033
else:
10131034
technical_detail += f": {response_text}"
10141035
except Exception:
10151036
technical_detail += f": {response_text}"
10161037

1038+
log_error_detail = technical_detail
10171039
_logger.error(technical_detail)
10181040
_logger.error("Full response body: %s", response_text)
10191041
_logger.error("Request envelope was: %s", envelope)
@@ -1031,26 +1053,126 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
10311053
error_str = str(e).lower()
10321054
if "timeout" in error_str or "timed out" in error_str:
10331055
connection_type = "timeout"
1056+
log_status = "timeout"
10341057
elif "ssl" in error_str or "certificate" in error_str:
10351058
connection_type = "ssl"
1059+
log_status = "connection_error"
10361060
elif "name or service not known" in error_str or "nodename nor servname" in error_str:
10371061
connection_type = "dns"
1062+
log_status = "connection_error"
10381063
else:
10391064
connection_type = "connection"
1065+
log_status = "connection_error"
1066+
1067+
log_error_detail = technical_detail
10401068

10411069
# Show user-friendly message
10421070
user_msg = format_connection_error(connection_type, technical_detail)
10431071
raise UserError(user_msg) from e
10441072

10451073
except Exception as e:
1074+
log_status = "error"
1075+
10461076
# Log technical details for troubleshooting
10471077
technical_detail = f"Unexpected error during DCI request: {str(e)}"
1078+
log_error_detail = technical_detail
10481079
_logger.error(technical_detail, exc_info=True)
10491080

10501081
# Show generic user-friendly message
10511082
user_msg = _("An unexpected error occurred. Please contact your administrator.")
10521083
raise UserError(user_msg) from e
10531084

1085+
finally:
1086+
duration_ms = int((time.monotonic() - start_time) * 1000)
1087+
self._log_outgoing_call(
1088+
url=url,
1089+
endpoint=endpoint,
1090+
envelope=envelope,
1091+
response_data=log_response_data,
1092+
status_code=log_status_code,
1093+
duration_ms=duration_ms,
1094+
status=log_status,
1095+
error_detail=log_error_detail,
1096+
)
1097+
1098+
# =========================================================================
1099+
# OUTGOING LOG
1100+
# =========================================================================
1101+
1102+
def _log_outgoing_call(
1103+
self,
1104+
url: str,
1105+
endpoint: str,
1106+
envelope: dict,
1107+
response_data: dict | None,
1108+
status_code: int | None,
1109+
duration_ms: int,
1110+
status: str,
1111+
error_detail: str | None,
1112+
):
1113+
"""Log an outgoing API call to spp.api.outgoing.log (soft dependency).
1114+
1115+
Uses runtime check to avoid hard manifest dependency on spp_api_v2.
1116+
Uses a separate database cursor so log entries persist even when the
1117+
caller's transaction is rolled back (e.g., on UserError).
1118+
Logging failures are swallowed so they never block the actual request.
1119+
"""
1120+
try:
1121+
if "spp.api.outgoing.log" not in self.env:
1122+
return
1123+
1124+
from odoo.addons.spp_api_v2.services.outgoing_api_log_service import OutgoingApiLogService
1125+
1126+
# Capture values from the current env before opening a new cursor,
1127+
# since self.data_source won't be accessible from the new cursor's env.
1128+
service_code = getattr(self.data_source, "code", None) or "dci"
1129+
origin_record_id = self.data_source.id if hasattr(self.data_source, "id") else None
1130+
user_id = self.env.uid
1131+
sanitized_envelope = self._copy_envelope_for_log(envelope)
1132+
1133+
# Use a separate cursor so log entries survive transaction rollback.
1134+
with self.env.registry.cursor() as new_cr:
1135+
new_env = self.env(cr=new_cr)
1136+
service = OutgoingApiLogService(
1137+
new_env,
1138+
service_name="DCI Client",
1139+
service_code=service_code,
1140+
user_id=user_id,
1141+
)
1142+
1143+
service.log_call(
1144+
url=url,
1145+
endpoint=endpoint,
1146+
http_method="POST",
1147+
request_summary=sanitized_envelope,
1148+
response_summary=response_data,
1149+
response_status_code=status_code,
1150+
duration_ms=duration_ms,
1151+
origin_model="spp.dci.data.source",
1152+
origin_record_id=origin_record_id,
1153+
status=status,
1154+
error_detail=error_detail,
1155+
)
1156+
except Exception:
1157+
_logger.warning("Failed to log outgoing API call (non-blocking)", exc_info=True)
1158+
1159+
def _copy_envelope_for_log(self, envelope: dict) -> dict | None:
1160+
"""Copy the request envelope for audit log storage.
1161+
1162+
Returns a shallow copy suitable for JSON storage. The cryptographic
1163+
signature is preserved for auditability (non-repudiation).
1164+
1165+
Args:
1166+
envelope: Request envelope dict
1167+
1168+
Returns:
1169+
Copy of the envelope, or None if input is falsy
1170+
"""
1171+
if not envelope:
1172+
return None
1173+
1174+
return dict(envelope)
1175+
10541176
# =========================================================================
10551177
# HELPER METHODS
10561178
# =========================================================================

spp_dci_client/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33

44
from . import test_data_source
55
from . import test_client_service
6+
from . import test_outgoing_log_integration

0 commit comments

Comments
 (0)