22
33import logging
44
5+ import httpx
56from openinference .instrumentation .google_adk import GoogleADKInstrumentor
67from opentelemetry import metrics , trace
78from opentelemetry ._logs import set_logger_provider
1617from opentelemetry .sdk .metrics .export import PeriodicExportingMetricReader
1718from opentelemetry .sdk .trace .export import SimpleSpanProcessor
1819
20+ _logger = logging .getLogger (__name__ )
1921
20- def setup_otel () -> None :
21- """Set up OpenTelemetry tracing, logging and metrics."""
22+ _MAX_BODY_SIZE = 100 * 1024 # 100KB
23+ _capture_http_bodies = False # Set by setup_otel()
24+
25+
26+ def _is_text_content (content_type : str ) -> bool :
27+ """Check if content type is text-based and safe to log."""
28+ text_types = ("application/json" , "application/xml" , "text/" , "application/x-www-form-urlencoded" )
29+ return any (ct in content_type .lower () for ct in text_types )
30+
31+
32+ def _truncate_body (body : bytes ) -> str :
33+ """Safely truncate and decode body to string, limiting size."""
34+ if len (body ) > _MAX_BODY_SIZE :
35+ body = body [:_MAX_BODY_SIZE ]
36+ try :
37+ decoded = body .decode ("utf-8" , errors = "replace" )
38+ if len (body ) == _MAX_BODY_SIZE :
39+ decoded += f"\n ... [truncated, exceeded { _MAX_BODY_SIZE } bytes]"
40+ return decoded
41+ except Exception :
42+ _logger .exception ("Failed to decode body content" )
43+ return "[body decoding failed]"
44+
45+
46+ def request_hook (span : trace .Span , request : httpx .Request ) -> None :
47+ """Hook to capture request body in traces if enabled."""
48+ if not _capture_http_bodies :
49+ return
50+
51+ try :
52+ # Skip streaming requests to avoid consuming the stream
53+ if hasattr (request , "stream" ) and request .stream is not None :
54+ return
55+
56+ content_type = request .headers .get ("content-type" , "" )
57+ if _is_text_content (content_type ) and hasattr (request , "content" ) and request .content :
58+ span .set_attribute ("http.request.body" , _truncate_body (request .content ))
59+ except Exception :
60+ _logger .exception ("Failed to capture request body in trace" )
61+
62+
63+ def response_hook (span : trace .Span , request : httpx .Request , response : httpx .Response ) -> None :
64+ """Hook to capture response body in traces if enabled."""
65+ if not _capture_http_bodies :
66+ return
67+
68+ try :
69+ # Skip streaming responses to avoid consuming the stream
70+ # Check both the is_stream_consumed flag and if stream is still active
71+ if hasattr (response , "is_stream_consumed" ) and not response .is_stream_consumed :
72+ return
73+
74+ content_type = response .headers .get ("content-type" , "" )
75+ if _is_text_content (content_type ) and hasattr (response , "content" ) and response .content :
76+ span .set_attribute ("http.response.body" , _truncate_body (response .content ))
77+ except Exception :
78+ _logger .exception ("Failed to capture response body in trace" )
79+
80+
81+ def setup_otel (capture_http_bodies : bool = False ) -> None :
82+ """Set up OpenTelemetry tracing, logging and metrics.
83+
84+ Args:
85+ capture_http_bodies: Enable capturing HTTP request/response bodies in traces.
86+ Only text-based content types are logged, truncated to 100KB.
87+ Streaming requests/responses are skipped to avoid consuming streams.
88+ Defaults to False for security/privacy reasons.
89+ """
90+ global _capture_http_bodies
91+ _capture_http_bodies = capture_http_bodies
2292
2393 # Set log level for urllib to WARNING to reduce noise (like sending logs to OTLP)
2494 logging .getLogger ("urllib3" ).setLevel (logging .WARNING )
@@ -32,7 +102,10 @@ def setup_otel() -> None:
32102 # Instrument Google ADK using openinference instrumentation
33103 GoogleADKInstrumentor ().instrument ()
34104 # Instrument HTTPX clients (this also transfers the trace context automatically)
35- HTTPXClientInstrumentor ().instrument ()
105+ HTTPXClientInstrumentor ().instrument (
106+ request_hook = request_hook ,
107+ response_hook = response_hook ,
108+ )
36109
37110 # Logs
38111 logger_provider = LoggerProvider ()
0 commit comments