Skip to content

Commit a7d70b2

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 130d730 commit a7d70b2

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
@@ -904,6 +905,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
904905
# Get headers from data source (includes auth)
905906
headers = self.data_source.get_headers()
906907

908+
# Track timing and result for outgoing log
909+
start_time = time.monotonic()
910+
log_status = "success"
911+
log_status_code = None
912+
log_response_data = None
913+
log_error_detail = None
914+
907915
try:
908916
_logger.info(
909917
"Making DCI request to %s (action: %s)",
@@ -930,6 +938,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
930938
# Handle 401 Unauthorized - try refreshing token once
931939
if response.status_code == 401 and _retry_auth and self.data_source.auth_type == "oauth2":
932940
_logger.warning("Got 401 Unauthorized, clearing OAuth2 token cache and retrying with fresh token")
941+
log_status = "http_error"
942+
log_status_code = 401
943+
log_error_detail = "401 Unauthorized - retrying with fresh token"
944+
try:
945+
log_response_data = response.json()
946+
except Exception:
947+
log_response_data = None
933948
self.data_source.clear_oauth2_token_cache()
934949
return self._make_request(endpoint, envelope, _retry_auth=False)
935950

@@ -938,6 +953,8 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
938953

939954
# Parse response
940955
response_data = response.json()
956+
log_status_code = response.status_code
957+
log_response_data = response_data
941958

942959
_logger.info(
943960
"DCI request successful (status: %s, message_id: %s)",
@@ -949,18 +966,23 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
949966
return response_data
950967

951968
except httpx.HTTPStatusError as e:
969+
log_status = "http_error"
970+
log_status_code = e.response.status_code
971+
952972
# Log technical details for troubleshooting
953973
technical_detail = f"DCI request failed with status {e.response.status_code}"
954974
response_text = e.response.text
955975
try:
956976
error_data = e.response.json()
977+
log_response_data = error_data
957978
if "header" in error_data and "status_reason_message" in error_data["header"]:
958979
technical_detail += f": {error_data['header']['status_reason_message']}"
959980
else:
960981
technical_detail += f": {response_text}"
961982
except Exception:
962983
technical_detail += f": {response_text}"
963984

985+
log_error_detail = technical_detail
964986
_logger.error(technical_detail)
965987
_logger.error("Full response body: %s", response_text)
966988
_logger.error("Request envelope was: %s", envelope)
@@ -978,26 +1000,126 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
9781000
error_str = str(e).lower()
9791001
if "timeout" in error_str or "timed out" in error_str:
9801002
connection_type = "timeout"
1003+
log_status = "timeout"
9811004
elif "ssl" in error_str or "certificate" in error_str:
9821005
connection_type = "ssl"
1006+
log_status = "connection_error"
9831007
elif "name or service not known" in error_str or "nodename nor servname" in error_str:
9841008
connection_type = "dns"
1009+
log_status = "connection_error"
9851010
else:
9861011
connection_type = "connection"
1012+
log_status = "connection_error"
1013+
1014+
log_error_detail = technical_detail
9871015

9881016
# Show user-friendly message
9891017
user_msg = format_connection_error(connection_type, technical_detail)
9901018
raise UserError(user_msg) from e
9911019

9921020
except Exception as e:
1021+
log_status = "error"
1022+
9931023
# Log technical details for troubleshooting
9941024
technical_detail = f"Unexpected error during DCI request: {str(e)}"
1025+
log_error_detail = technical_detail
9951026
_logger.error(technical_detail, exc_info=True)
9961027

9971028
# Show generic user-friendly message
9981029
user_msg = _("An unexpected error occurred. Please contact your administrator.")
9991030
raise UserError(user_msg) from e
10001031

1032+
finally:
1033+
duration_ms = int((time.monotonic() - start_time) * 1000)
1034+
self._log_outgoing_call(
1035+
url=url,
1036+
endpoint=endpoint,
1037+
envelope=envelope,
1038+
response_data=log_response_data,
1039+
status_code=log_status_code,
1040+
duration_ms=duration_ms,
1041+
status=log_status,
1042+
error_detail=log_error_detail,
1043+
)
1044+
1045+
# =========================================================================
1046+
# OUTGOING LOG
1047+
# =========================================================================
1048+
1049+
def _log_outgoing_call(
1050+
self,
1051+
url: str,
1052+
endpoint: str,
1053+
envelope: dict,
1054+
response_data: dict | None,
1055+
status_code: int | None,
1056+
duration_ms: int,
1057+
status: str,
1058+
error_detail: str | None,
1059+
):
1060+
"""Log an outgoing API call to spp.api.outgoing.log (soft dependency).
1061+
1062+
Uses runtime check to avoid hard manifest dependency on spp_api_v2.
1063+
Uses a separate database cursor so log entries persist even when the
1064+
caller's transaction is rolled back (e.g., on UserError).
1065+
Logging failures are swallowed so they never block the actual request.
1066+
"""
1067+
try:
1068+
if "spp.api.outgoing.log" not in self.env:
1069+
return
1070+
1071+
from odoo.addons.spp_api_v2.services.outgoing_api_log_service import OutgoingApiLogService
1072+
1073+
# Capture values from the current env before opening a new cursor,
1074+
# since self.data_source won't be accessible from the new cursor's env.
1075+
service_code = getattr(self.data_source, "code", None) or "dci"
1076+
origin_record_id = self.data_source.id if hasattr(self.data_source, "id") else None
1077+
user_id = self.env.uid
1078+
sanitized_envelope = self._copy_envelope_for_log(envelope)
1079+
1080+
# Use a separate cursor so log entries survive transaction rollback.
1081+
with self.env.registry.cursor() as new_cr:
1082+
new_env = self.env(cr=new_cr)
1083+
service = OutgoingApiLogService(
1084+
new_env,
1085+
service_name="DCI Client",
1086+
service_code=service_code,
1087+
user_id=user_id,
1088+
)
1089+
1090+
service.log_call(
1091+
url=url,
1092+
endpoint=endpoint,
1093+
http_method="POST",
1094+
request_summary=sanitized_envelope,
1095+
response_summary=response_data,
1096+
response_status_code=status_code,
1097+
duration_ms=duration_ms,
1098+
origin_model="spp.dci.data.source",
1099+
origin_record_id=origin_record_id,
1100+
status=status,
1101+
error_detail=error_detail,
1102+
)
1103+
except Exception:
1104+
_logger.warning("Failed to log outgoing API call (non-blocking)", exc_info=True)
1105+
1106+
def _copy_envelope_for_log(self, envelope: dict) -> dict | None:
1107+
"""Copy the request envelope for audit log storage.
1108+
1109+
Returns a shallow copy suitable for JSON storage. The cryptographic
1110+
signature is preserved for auditability (non-repudiation).
1111+
1112+
Args:
1113+
envelope: Request envelope dict
1114+
1115+
Returns:
1116+
Copy of the envelope, or None if input is falsy
1117+
"""
1118+
if not envelope:
1119+
return None
1120+
1121+
return dict(envelope)
1122+
10011123
# =========================================================================
10021124
# HELPER METHODS
10031125
# =========================================================================

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)