""" s3 Bucket Logging Integration async_log_success_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3 async_log_failure_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3 NOTE 1: S3 does not provide a BATCH PUT API endpoint, so we create tasks to upload each element individually """ import asyncio import time from datetime import datetime from typing import List, Optional, cast import litellm from litellm._logging import print_verbose, verbose_logger from litellm.constants import DEFAULT_S3_BATCH_SIZE, DEFAULT_S3_FLUSH_INTERVAL_SECONDS from litellm.integrations.s3 import get_s3_object_key from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, get_async_httpx_client, httpxSpecialProvider, ) from litellm.types.integrations.s3_v2 import s3BatchLoggingElement from litellm.types.utils import StandardAuditLogPayload, StandardLoggingPayload from .custom_batch_logger import CustomBatchLogger class S3Logger(CustomBatchLogger, BaseAWSLLM): def __init__( self, s3_bucket_name: Optional[str] = None, s3_path: Optional[str] = None, s3_region_name: Optional[str] = None, s3_api_version: Optional[str] = None, s3_use_ssl: bool = True, s3_verify: Optional[bool] = None, s3_endpoint_url: Optional[str] = None, s3_aws_access_key_id: Optional[str] = None, s3_aws_secret_access_key: Optional[str] = None, s3_aws_session_token: Optional[str] = None, s3_aws_session_name: Optional[str] = None, s3_aws_profile_name: Optional[str] = None, s3_aws_role_name: Optional[str] = None, s3_aws_web_identity_token: Optional[str] = None, s3_aws_sts_endpoint: Optional[str] = None, s3_flush_interval: Optional[int] = DEFAULT_S3_FLUSH_INTERVAL_SECONDS, s3_batch_size: Optional[int] = DEFAULT_S3_BATCH_SIZE, s3_config=None, s3_use_team_prefix: bool = False, s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, s3_use_virtual_hosted_style: bool = False, s3_callback_params_override: Optional[dict] = None, **kwargs, ): try: _masker = SensitiveDataMasker() if s3_callback_params_override is not None: verbose_logger.debug( f"in init s3 logger (audit override) - " f"{_masker.mask_dict(dict(s3_callback_params_override))}" ) else: verbose_logger.debug( f"in init s3 logger - s3_callback_params " f"{_masker.mask_dict(dict(litellm.s3_callback_params or {}))}" ) # Initialize S3 params first to get the correct s3_verify value self._init_s3_params( params_source=s3_callback_params_override, s3_bucket_name=s3_bucket_name, s3_region_name=s3_region_name, s3_api_version=s3_api_version, s3_use_ssl=s3_use_ssl, s3_verify=s3_verify, s3_endpoint_url=s3_endpoint_url, s3_aws_access_key_id=s3_aws_access_key_id, s3_aws_secret_access_key=s3_aws_secret_access_key, s3_aws_session_token=s3_aws_session_token, s3_aws_session_name=s3_aws_session_name, s3_aws_profile_name=s3_aws_profile_name, s3_aws_role_name=s3_aws_role_name, s3_aws_web_identity_token=s3_aws_web_identity_token, s3_aws_sts_endpoint=s3_aws_sts_endpoint, s3_config=s3_config, s3_path=s3_path, s3_use_team_prefix=s3_use_team_prefix, s3_strip_base64_files=s3_strip_base64_files, s3_use_key_prefix=s3_use_key_prefix, s3_use_virtual_hosted_style=s3_use_virtual_hosted_style, ) verbose_logger.debug(f"s3 logger using endpoint url {s3_endpoint_url}") # IMPORTANT # Create httpx client AFTER _init_s3_params so we have the correct s3_verify value verbose_logger.debug( f"s3_v2 logger creating async httpx client with s3_verify={self.s3_verify}" ) self.async_httpx_client = get_async_httpx_client( llm_provider=httpxSpecialProvider.LoggingCallback, params={"ssl_verify": self.s3_verify}, ) asyncio.create_task(self.periodic_flush()) self.flush_lock = asyncio.Lock() verbose_logger.debug( f"s3 flush interval: {s3_flush_interval}, s3 batch size: {s3_batch_size}" ) # Call CustomLogger's __init__ CustomBatchLogger.__init__( self, flush_lock=self.flush_lock, flush_interval=s3_flush_interval, batch_size=s3_batch_size, ) self.log_queue: List[s3BatchLoggingElement] = [] # Call BaseAWSLLM's __init__ BaseAWSLLM.__init__(self) except Exception as e: print_verbose(f"Got exception on init s3 client {str(e)}") raise e def _init_s3_params( self, s3_bucket_name: Optional[str] = None, s3_region_name: Optional[str] = None, s3_api_version: Optional[str] = None, s3_use_ssl: bool = True, s3_verify: Optional[bool] = None, s3_endpoint_url: Optional[str] = None, s3_aws_access_key_id: Optional[str] = None, s3_aws_secret_access_key: Optional[str] = None, s3_aws_session_token: Optional[str] = None, s3_aws_session_name: Optional[str] = None, s3_aws_profile_name: Optional[str] = None, s3_aws_role_name: Optional[str] = None, s3_aws_web_identity_token: Optional[str] = None, s3_aws_sts_endpoint: Optional[str] = None, s3_config=None, s3_path: Optional[str] = None, s3_use_team_prefix: bool = False, s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, s3_use_virtual_hosted_style: bool = False, params_source: Optional[dict] = None, ): """ Initialize the s3 params for this logging callback. Reads from `params_source` if given (e.g. `s3_audit_callback_params` for the audit-log instance), otherwise falls back to `litellm.s3_callback_params`. Resolves `os.environ/X` markers into a local dict; never mutates the source. """ if params_source is None: params_source = litellm.s3_callback_params or {} params: dict = { key: ( litellm.get_secret(value) if isinstance(value, str) and value.startswith("os.environ/") else value ) for key, value in params_source.items() } self.s3_bucket_name = params.get("s3_bucket_name") or s3_bucket_name self.s3_region_name = params.get("s3_region_name") or s3_region_name self.s3_api_version = params.get("s3_api_version") or s3_api_version self.s3_use_ssl = ( params.get("s3_use_ssl", True) if params.get("s3_use_ssl") is not None else s3_use_ssl ) self.s3_verify = ( params.get("s3_verify") if params.get("s3_verify") is not None else s3_verify ) self.s3_endpoint_url = params.get("s3_endpoint_url") or s3_endpoint_url self.s3_aws_access_key_id = ( params.get("s3_aws_access_key_id") or s3_aws_access_key_id ) self.s3_aws_secret_access_key = ( params.get("s3_aws_secret_access_key") or s3_aws_secret_access_key ) self.s3_aws_session_token = ( params.get("s3_aws_session_token") or s3_aws_session_token ) self.s3_aws_session_name = ( params.get("s3_aws_session_name") or s3_aws_session_name ) self.s3_aws_profile_name = ( params.get("s3_aws_profile_name") or s3_aws_profile_name ) self.s3_aws_role_name = params.get("s3_aws_role_name") or s3_aws_role_name self.s3_aws_web_identity_token = ( params.get("s3_aws_web_identity_token") or s3_aws_web_identity_token ) self.s3_aws_sts_endpoint = ( params.get("s3_aws_sts_endpoint") or s3_aws_sts_endpoint ) self.s3_config = params.get("s3_config") or s3_config self.s3_path = params.get("s3_path") or s3_path self.s3_use_team_prefix = ( bool(params.get("s3_use_team_prefix", False)) or s3_use_team_prefix ) self.s3_use_key_prefix = ( bool(params.get("s3_use_key_prefix", False)) or s3_use_key_prefix ) self.s3_strip_base64_files = ( bool(params.get("s3_strip_base64_files", False)) or s3_strip_base64_files ) self.s3_use_virtual_hosted_style = ( bool(params.get("s3_use_virtual_hosted_style", False)) or s3_use_virtual_hosted_style ) return async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): await self._async_log_event_base( kwargs=kwargs, response_obj=response_obj, start_time=start_time, end_time=end_time, ) async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): await self._async_log_event_base( kwargs=kwargs, response_obj=response_obj, start_time=start_time, end_time=end_time, ) pass async def async_log_audit_log_event( self, audit_log: StandardAuditLogPayload ) -> None: """Batch audit logs and upload to S3 under audit_logs/ prefix.""" try: from datetime import timezone now = datetime.now(timezone.utc) audit_log_id = audit_log.get("id", "unknown") s3_path = cast(Optional[str], self.s3_path) or "" s3_path = s3_path.rstrip("/") + "/" if s3_path else "" s3_object_key = ( f"{s3_path}audit_logs/" f"{now.strftime('%Y-%m-%d')}/" f"{now.strftime('%H-%M-%S')}_{audit_log_id}.json" ) element = s3BatchLoggingElement( payload=dict(audit_log), s3_object_key=s3_object_key, s3_object_download_filename=f"audit-{audit_log_id}.json", ) self.log_queue.append(element) if len(self.log_queue) >= self.batch_size: await self.flush_queue() except Exception as e: verbose_logger.exception("S3 audit log error: %s", e) async def _async_log_event_base(self, kwargs, response_obj, start_time, end_time): try: verbose_logger.debug( f"s3 Logging - Enters logging function for model {kwargs}" ) s3_batch_logging_element = self.create_s3_batch_logging_element( start_time=start_time, standard_logging_payload=kwargs.get("standard_logging_object", None), ) # afile_delete and other non-model call types never produce a standard_logging_object, # so s3_batch_logging_element is None. Skip gracefully instead of raising ValueError. if s3_batch_logging_element is None: verbose_logger.debug( "s3 Logging - skipping event, no standard_logging_object for call_type=%s", kwargs.get("call_type", "unknown"), ) return verbose_logger.debug( "\ns3 Logger - Logging payload = %s", s3_batch_logging_element ) self.log_queue.append(s3_batch_logging_element) verbose_logger.debug( "s3 logging: queue length %s, batch size %s", len(self.log_queue), self.batch_size, ) except Exception as e: verbose_logger.exception(f"s3 Layer Error - {str(e)}") self.handle_callback_failure(callback_name="S3Logger") async def async_upload_data_to_s3( self, batch_logging_element: s3BatchLoggingElement ): try: import hashlib import requests from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest except ImportError: raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") try: from litellm.litellm_core_utils.asyncify import asyncify asyncified_get_credentials = asyncify(self.get_credentials) credentials = await asyncified_get_credentials( aws_access_key_id=self.s3_aws_access_key_id, aws_secret_access_key=self.s3_aws_secret_access_key, aws_session_token=self.s3_aws_session_token, aws_region_name=self.s3_region_name, aws_session_name=self.s3_aws_session_name, aws_profile_name=self.s3_aws_profile_name, aws_role_name=self.s3_aws_role_name, aws_web_identity_token=self.s3_aws_web_identity_token, aws_sts_endpoint=self.s3_aws_sts_endpoint, ) verbose_logger.debug( f"s3_v2 logger - uploading data to s3 - {batch_logging_element.s3_object_key}" ) verbose_logger.debug(f"s3_v2 logger - s3_verify setting: {self.s3_verify}") # Prepare the URL url = f"https://{self.s3_bucket_name}.s3.{self.s3_region_name}.amazonaws.com/{batch_logging_element.s3_object_key}" if self.s3_endpoint_url and self.s3_bucket_name: if self.s3_use_virtual_hosted_style: # Virtual-hosted-style: bucket.endpoint/key endpoint_host = self.s3_endpoint_url.replace( "https://", "" ).replace("http://", "") protocol = ( "https://" if self.s3_endpoint_url.startswith("https://") else "http://" ) url = f"{protocol}{self.s3_bucket_name}.{endpoint_host}/{batch_logging_element.s3_object_key}" else: # Path-style: endpoint/bucket/key url = ( self.s3_endpoint_url + "/" + self.s3_bucket_name + "/" + batch_logging_element.s3_object_key ) # Convert JSON to string json_string = safe_dumps(batch_logging_element.payload) # Calculate SHA256 hash of the content content_hash = hashlib.sha256(json_string.encode("utf-8")).hexdigest() # Prepare the request headers = { "Content-Type": "application/json", "x-amz-content-sha256": content_hash, "Content-Language": "en", "Content-Disposition": f'inline; filename="{batch_logging_element.s3_object_download_filename}"', "Cache-Control": "private, immutable, max-age=31536000, s-maxage=0", } req = requests.Request("PUT", url, data=json_string, headers=headers) prepped = req.prepare() # Sign the request aws_request = AWSRequest( method=prepped.method, url=prepped.url, data=prepped.body, headers=prepped.headers, ) aws_region_name = self.get_aws_region_name_for_non_llm_api_calls( aws_region_name=self.s3_region_name ) SigV4Auth(credentials, "s3", aws_region_name).add_auth(aws_request) # Prepare the signed headers signed_headers = dict(aws_request.headers.items()) # Use prepared URL so path segments match SigV4 canonical request (e.g. %20 for spaces). request_url = prepped.url or url # Make the request with retry for transient S3 errors (500/503) max_retries = 3 for attempt in range(max_retries): response = await self.async_httpx_client.put( request_url, data=json_string, headers=signed_headers ) if response.status_code in (500, 503) and attempt < max_retries - 1: wait_time = 2**attempt # 1s, 2s verbose_logger.warning( f"S3 upload returned {response.status_code}, retrying in {wait_time}s " f"(attempt {attempt + 1}/{max_retries}) " f"key={batch_logging_element.s3_object_key}" ) await asyncio.sleep(wait_time) continue response.raise_for_status() break except Exception as e: verbose_logger.exception(f"Error uploading to s3: {str(e)}") self.handle_callback_failure(callback_name="S3Logger") async def async_send_batch(self): """ Sends runs from self.log_queue Returns: None Raises: Does not raise an exception, will only verbose_logger.exception() """ verbose_logger.debug(f"s3_v2 logger - sending batch of {len(self.log_queue)}") if not self.log_queue: return ######################################################### # Flush the log queue to s3 # the log queue can be bounded by DEFAULT_S3_BATCH_SIZE # see custom_batch_logger.py which triggers the flush ######################################################### for payload in self.log_queue: asyncio.create_task(self.async_upload_data_to_s3(payload)) def create_s3_batch_logging_element( self, start_time: datetime, standard_logging_payload: Optional[StandardLoggingPayload], ) -> Optional[s3BatchLoggingElement]: """ Helper function to create an s3BatchLoggingElement. Args: start_time (datetime): The start time of the logging event. standard_logging_payload (Optional[StandardLoggingPayload]): The payload to be logged. s3_path (Optional[str]): The S3 path prefix. Returns: Optional[s3BatchLoggingElement]: The created s3BatchLoggingElement, or None if payload is None. """ if standard_logging_payload is None: return None if self.s3_strip_base64_files: standard_logging_payload = self._strip_base64_from_messages_sync( standard_logging_payload ) # Base prefix (default empty) prefix_components = [] if self.s3_use_team_prefix: team_alias = standard_logging_payload.get("metadata", {}).get( "user_api_key_team_alias", None ) if team_alias: prefix_components.append(team_alias) if self.s3_use_key_prefix: user_api_key_alias = standard_logging_payload.get("metadata", {}).get( "user_api_key_alias", None ) if user_api_key_alias: prefix_components.append(user_api_key_alias) # Construct full prefix path prefix_path = "/".join(prefix_components) if prefix_path: prefix_path += "/" s3_file_name = ( litellm.utils.get_logging_id(start_time, standard_logging_payload) or "" ) verbose_logger.debug( f"Creating s3 file with prefix_components={prefix_components},prefix_path={prefix_path} and {s3_file_name}" ) s3_object_key = get_s3_object_key( s3_path=cast(Optional[str], self.s3_path) or "", prefix=prefix_path, start_time=start_time, s3_file_name=s3_file_name, ) verbose_logger.debug(f"s3_object_key={s3_object_key}") s3_object_download_filename = f"time-{start_time.strftime('%Y-%m-%dT%H-%M-%S-%f')}_{standard_logging_payload['id']}.json" return s3BatchLoggingElement( payload=dict(standard_logging_payload), s3_object_key=s3_object_key, s3_object_download_filename=s3_object_download_filename, ) def upload_data_to_s3(self, batch_logging_element: s3BatchLoggingElement): try: import hashlib import requests from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest from botocore.credentials import Credentials except ImportError: raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") try: verbose_logger.debug( f"s3_v2 logger - uploading data to s3 - {batch_logging_element.s3_object_key}" ) credentials: Credentials = self.get_credentials( aws_access_key_id=self.s3_aws_access_key_id, aws_secret_access_key=self.s3_aws_secret_access_key, aws_session_token=self.s3_aws_session_token, aws_region_name=self.s3_region_name, ) # Prepare the URL url = f"https://{self.s3_bucket_name}.s3.{self.s3_region_name}.amazonaws.com/{batch_logging_element.s3_object_key}" if self.s3_endpoint_url and self.s3_bucket_name: if self.s3_use_virtual_hosted_style: # Virtual-hosted-style: bucket.endpoint/key endpoint_host = self.s3_endpoint_url.replace( "https://", "" ).replace("http://", "") protocol = ( "https://" if self.s3_endpoint_url.startswith("https://") else "http://" ) url = f"{protocol}{self.s3_bucket_name}.{endpoint_host}/{batch_logging_element.s3_object_key}" else: # Path-style: endpoint/bucket/key url = ( self.s3_endpoint_url + "/" + self.s3_bucket_name + "/" + batch_logging_element.s3_object_key ) # Convert JSON to string json_string = safe_dumps(batch_logging_element.payload) # Calculate SHA256 hash of the content content_hash = hashlib.sha256(json_string.encode("utf-8")).hexdigest() # Prepare the request headers = { "Content-Type": "application/json", "x-amz-content-sha256": content_hash, "Content-Language": "en", "Content-Disposition": f'inline; filename="{batch_logging_element.s3_object_download_filename}"', "Cache-Control": "private, immutable, max-age=31536000, s-maxage=0", } req = requests.Request("PUT", url, data=json_string, headers=headers) prepped = req.prepare() # Sign the request aws_request = AWSRequest( method=prepped.method, url=prepped.url, data=prepped.body, headers=prepped.headers, ) aws_region_name = self.get_aws_region_name_for_non_llm_api_calls( aws_region_name=self.s3_region_name ) SigV4Auth(credentials, "s3", aws_region_name).add_auth(aws_request) # Prepare the signed headers signed_headers = dict(aws_request.headers.items()) # Use prepared URL so path segments match SigV4 canonical request (e.g. %20 for spaces). request_url = prepped.url or url httpx_client = _get_httpx_client( params=( {"ssl_verify": self.s3_verify} if self.s3_verify is not None else None ) ) # Make the request with retry for transient S3 errors (500/503) max_retries = 3 for attempt in range(max_retries): response = httpx_client.put( request_url, data=json_string, headers=signed_headers ) if response.status_code in (500, 503) and attempt < max_retries - 1: wait_time = 2**attempt # 1s, 2s verbose_logger.warning( f"S3 upload returned {response.status_code}, retrying in {wait_time}s " f"(attempt {attempt + 1}/{max_retries}) " f"key={batch_logging_element.s3_object_key}" ) time.sleep(wait_time) continue response.raise_for_status() break except Exception as e: verbose_logger.exception(f"Error uploading to s3: {str(e)}") self.handle_callback_failure(callback_name="S3Logger") async def _download_object_from_s3(self, s3_object_key: str) -> Optional[dict]: """ Download and parse JSON object from S3. Args: s3_object_key: The S3 object key to download Returns: Optional[dict]: The parsed JSON object or None if not found/error """ try: import hashlib import requests from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest except ImportError: raise ImportError("Missing boto3 to call S3. Run 'pip install boto3'.") try: from litellm.litellm_core_utils.asyncify import asyncify # Get AWS credentials asyncified_get_credentials = asyncify(self.get_credentials) credentials = await asyncified_get_credentials( aws_access_key_id=self.s3_aws_access_key_id, aws_secret_access_key=self.s3_aws_secret_access_key, aws_session_token=self.s3_aws_session_token, aws_region_name=self.s3_region_name, aws_session_name=self.s3_aws_session_name, aws_profile_name=self.s3_aws_profile_name, aws_role_name=self.s3_aws_role_name, aws_web_identity_token=self.s3_aws_web_identity_token, aws_sts_endpoint=self.s3_aws_sts_endpoint, ) verbose_logger.debug( f"s3_v2 logger - downloading data from s3 - {s3_object_key}" ) # Prepare the URL url = f"https://{self.s3_bucket_name}.s3.{self.s3_region_name}.amazonaws.com/{s3_object_key}" if self.s3_endpoint_url and self.s3_bucket_name: if self.s3_use_virtual_hosted_style: # Virtual-hosted-style: bucket.endpoint/key endpoint_host = self.s3_endpoint_url.replace( "https://", "" ).replace("http://", "") protocol = ( "https://" if self.s3_endpoint_url.startswith("https://") else "http://" ) url = f"{protocol}{self.s3_bucket_name}.{endpoint_host}/{s3_object_key}" else: # Path-style: endpoint/bucket/key url = ( self.s3_endpoint_url + "/" + self.s3_bucket_name + "/" + s3_object_key ) # Prepare the request for GET operation # For GET requests, we need x-amz-content-sha256 with hash of empty string empty_string_hash = hashlib.sha256(b"").hexdigest() headers = { "x-amz-content-sha256": empty_string_hash, } req = requests.Request("GET", url, headers=headers) prepped = req.prepare() # Sign the request aws_request = AWSRequest( method=prepped.method, url=prepped.url, headers=prepped.headers, ) SigV4Auth(credentials, "s3", self.s3_region_name).add_auth(aws_request) # Prepare the signed headers signed_headers = dict(aws_request.headers.items()) request_url = prepped.url or url response = await self.async_httpx_client.get( request_url, headers=signed_headers ) if response.status_code != 200: verbose_logger.exception( "S3 object not found, saw response=", response.text ) return None # Parse JSON response return response.json() except Exception as e: verbose_logger.exception(f"Error downloading from S3: {str(e)}") return None async def get_proxy_server_request_from_cold_storage_with_object_key( self, object_key: str, ) -> Optional[dict]: """ Get the proxy server request from cold storage Allows fetching a dict of the proxy server request from s3 or GCS bucket. Args: request_id: The unique request ID to search for start_time: The start time of the request (datetime or ISO string) Returns: Optional[dict]: The request data dictionary or None if not found """ try: # Download and return the object from S3 downloaded_object = await self._download_object_from_s3(object_key) return downloaded_object except Exception as e: verbose_logger.exception( f"Error retrieving object {object_key} from cold storage: {str(e)}" ) return None