11"""DCI Client Service for making signed API requests."""
22
33import logging
4+ import time
45import uuid
56from datetime import UTC , datetime
67from 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 # =========================================================================
0 commit comments