11# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22# SPDX-License-Identifier: Apache-2.0
33
4- from typing import Dict , Optional
4+ import gzip
5+ import logging
6+ from io import BytesIO
7+ from time import sleep
8+ from typing import Dict , Optional , Sequence
9+
10+ import requests
511
612from amazon .opentelemetry .distro .exporter .otlp .aws .common .aws_auth_session import AwsAuthSession
13+ from opentelemetry .exporter .otlp .proto .common ._internal import (
14+ _create_exp_backoff_generator ,
15+ )
16+ from opentelemetry .exporter .otlp .proto .common ._log_encoder import encode_logs
717from opentelemetry .exporter .otlp .proto .http import Compression
818from opentelemetry .exporter .otlp .proto .http ._log_exporter import OTLPLogExporter
19+ from opentelemetry .sdk ._logs import (
20+ LogData ,
21+ )
22+ from opentelemetry .sdk ._logs .export import (
23+ LogExportResult ,
24+ )
25+
26+ _logger = logging .getLogger (__name__ )
927
1028
1129class OTLPAwsLogExporter (OTLPLogExporter ):
30+ _LARGE_LOG_HEADER = "x-aws-truncatable-fields"
31+ _LARGE_GEN_AI_LOG_PATH_HEADER = (
32+ "\\ $['resourceLogs'][0]['scopeLogs'][0]['logRecords'][0]['body']"
33+ "['kvlistValue']['values'][*]['value']['kvlistValue']['values'][*]"
34+ "['value']['arrayValue']['values'][*]['kvlistValue']['values'][*]"
35+ "['value']['stringValue']"
36+ )
37+ _RETRY_AFTER_HEADER = "Retry-After" # https://opentelemetry.io/docs/specs/otlp/#otlphttp-throttling
38+
1239 def __init__ (
1340 self ,
1441 endpoint : Optional [str ] = None ,
@@ -18,6 +45,7 @@ def __init__(
1845 headers : Optional [Dict [str , str ]] = None ,
1946 timeout : Optional [int ] = None ,
2047 ):
48+ self ._gen_ai_log_flag = False
2149 self ._aws_region = None
2250
2351 if endpoint :
@@ -34,3 +62,134 @@ def __init__(
3462 compression = Compression .Gzip ,
3563 session = AwsAuthSession (aws_region = self ._aws_region , service = "logs" ),
3664 )
65+
66+ # https://github.com/open-telemetry/opentelemetry-python/blob/main/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py#L167
67+ def export (self , batch : Sequence [LogData ]) -> LogExportResult :
68+ """
69+ Exports the given batch of OTLP log data.
70+ Behaviors of how this export will work -
71+
72+ 1. Always compresses the serialized data into gzip before sending.
73+
74+ 2. If self._gen_ai_log_flag is enabled, the log data is > 1 MB a
75+ and the assumption is that the log is a normalized gen.ai LogEvent.
76+ - inject the {LARGE_LOG_HEADER} into the header.
77+
78+ 3. Retry behavior is now the following:
79+ - if the response contains a status code that is retryable and the response contains Retry-After in its
80+ headers, the serialized data will be exported after that set delay
81+
82+ - if the response does not contain that Retry-After header, default back to the current iteration of the
83+ exponential backoff delay
84+ """
85+
86+ if self ._shutdown :
87+ _logger .warning ("Exporter already shutdown, ignoring batch" )
88+ return LogExportResult .FAILURE
89+
90+ serialized_data = encode_logs (batch ).SerializeToString ()
91+
92+ gzip_data = BytesIO ()
93+ with gzip .GzipFile (fileobj = gzip_data , mode = "w" ) as gzip_stream :
94+ gzip_stream .write (serialized_data )
95+
96+ data = gzip_data .getvalue ()
97+
98+ backoff = _create_exp_backoff_generator (max_value = self ._MAX_RETRY_TIMEOUT )
99+
100+ while True :
101+ resp = self ._send (data )
102+
103+ if resp .ok :
104+ return LogExportResult .SUCCESS
105+
106+ if not self ._retryable (resp ):
107+ _logger .error (
108+ "Failed to export logs batch code: %s, reason: %s" ,
109+ resp .status_code ,
110+ resp .text ,
111+ )
112+ self ._gen_ai_log_flag = False
113+ return LogExportResult .FAILURE
114+
115+ # https://opentelemetry.io/docs/specs/otlp/#otlphttp-throttling
116+ maybe_retry_after = resp .headers .get (self ._RETRY_AFTER_HEADER , None )
117+
118+ # Set the next retry delay to the value of the Retry-After response in the headers.
119+ # If Retry-After is not present in the headers, default to the next iteration of the
120+ # exponential backoff strategy.
121+
122+ delay = self ._parse_retryable_header (maybe_retry_after )
123+
124+ if delay == - 1 :
125+ delay = next (backoff , self ._MAX_RETRY_TIMEOUT )
126+
127+ if delay == self ._MAX_RETRY_TIMEOUT :
128+ _logger .error (
129+ "Transient error %s encountered while exporting logs batch. "
130+ "No Retry-After header found and all backoff retries exhausted. "
131+ "Logs will not be exported." ,
132+ resp .reason ,
133+ )
134+ self ._gen_ai_log_flag = False
135+ return LogExportResult .FAILURE
136+
137+ _logger .warning (
138+ "Transient error %s encountered while exporting logs batch, retrying in %ss." ,
139+ resp .reason ,
140+ delay ,
141+ )
142+
143+ sleep (delay )
144+
145+ def set_gen_ai_log_flag (self ):
146+ """
147+ Sets a flag that indicates the current log batch contains
148+ a generative AI log record that exceeds the CloudWatch Logs size limit (1MB).
149+ """
150+ self ._gen_ai_log_flag = True
151+
152+ def _send (self , serialized_data : bytes ):
153+ try :
154+ response = self ._session .post (
155+ url = self ._endpoint ,
156+ headers = {self ._LARGE_LOG_HEADER : self ._LARGE_GEN_AI_LOG_PATH_HEADER } if self ._gen_ai_log_flag else None ,
157+ data = serialized_data ,
158+ verify = self ._certificate_file ,
159+ timeout = self ._timeout ,
160+ cert = self ._client_cert ,
161+ )
162+ return response
163+ except ConnectionError :
164+ response = self ._session .post (
165+ url = self ._endpoint ,
166+ headers = {self ._LARGE_LOG_HEADER : self ._LARGE_GEN_AI_LOG_PATH_HEADER } if self ._gen_ai_log_flag else None ,
167+ data = serialized_data ,
168+ verify = self ._certificate_file ,
169+ timeout = self ._timeout ,
170+ cert = self ._client_cert ,
171+ )
172+ return response
173+
174+ @staticmethod
175+ def _retryable (resp : requests .Response ) -> bool :
176+ """
177+ Is it a retryable response?
178+ """
179+
180+ return resp .status_code in (429 , 503 ) or OTLPLogExporter ._retryable (resp )
181+
182+ @staticmethod
183+ def _parse_retryable_header (retry_header : Optional [str ]) -> float :
184+ """
185+ Converts the given retryable header into a delay in seconds, returns -1 if there's no header
186+ or error with the parsing
187+ """
188+ if not retry_header :
189+ return - 1
190+
191+ try :
192+ val = float (retry_header )
193+ return val if val >= 0 else - 1
194+ except ValueError :
195+ return - 1
0 commit comments