Skip to content

Commit b43d3a3

Browse files
committed
feat: Add optional request/response body logging in traces
1 parent ba08c0e commit b43d3a3

File tree

4 files changed

+206
-9
lines changed

4 files changed

+206
-9
lines changed

adk/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ from agenticlayer.otel import setup_otel
2727
from google.adk.agents import LlmAgent
2828

2929
# Set up OpenTelemetry instrumentation, logging and metrics
30-
setup_otel()
30+
setup_otel(capture_http_bodies=True)
3131

3232
# Parse sub agents and tools from JSON configuration
3333
sub_agent, agent_tools = parse_sub_agents("{}")
@@ -81,3 +81,22 @@ The JSON configuration for `AGENT_TOOLS` should follow this structure:
8181
The SDK automatically configures OpenTelemetry observability when running `setup_otel()`. You can customize the OTLP
8282
exporters using standard OpenTelemetry environment variables:
8383
https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/
84+
85+
### HTTP Body Logging
86+
87+
By default, HTTP request/response bodies are not captured in traces for security and privacy reasons. To enable body
88+
logging for debugging purposes, pass `enable_body_logging=True` to `setup_otel()`.
89+
90+
When enabled, body logging applies to both:
91+
- **HTTPX client requests/responses** (outgoing HTTP calls)
92+
- **Starlette server requests/responses** (incoming HTTP requests to your app)
93+
94+
Body logging behavior:
95+
- Only text-based content types are logged (JSON, XML, plain text, form data)
96+
- Bodies are truncated to 100KB to prevent memory issues
97+
- Binary content (images, PDFs, etc.) is never logged
98+
- Streaming requests/responses are skipped to avoid consuming streams
99+
- All exceptions during body capture are logged but won't break HTTP requests
100+
101+
**Note**: Starlette body logging is more limited than HTTPX because it must avoid consuming request/response streams.
102+
Bodies are only captured when already buffered in the ASGI scope.

adk/agenticlayer/agent_to_a2a.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import contextlib
77
import logging
8-
import os
98
from typing import AsyncIterator, Awaitable, Callable
109

1110
from a2a.server.apps import A2AStarletteApplication
@@ -157,10 +156,8 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
157156
starlette_app = Starlette(lifespan=lifespan)
158157

159158
# Instrument the Starlette app with OpenTelemetry
160-
# env needs to be set here since _excluded_urls is initialized at module import time
161-
os.environ.setdefault("OTEL_PYTHON_STARLETTE_EXCLUDED_URLS", AGENT_CARD_WELL_KNOWN_PATH)
162-
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
159+
from .otel_starlette import instrument_starlette_app
163160

164-
StarletteInstrumentor().instrument_app(starlette_app)
161+
instrument_starlette_app(starlette_app)
165162

166163
return starlette_app

adk/agenticlayer/otel.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44

5+
import httpx
56
from openinference.instrumentation.google_adk import GoogleADKInstrumentor
67
from opentelemetry import metrics, trace
78
from opentelemetry._logs import set_logger_provider
@@ -16,9 +17,78 @@
1617
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
1718
from 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()

adk/agenticlayer/otel_starlette.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""OpenTelemetry instrumentation for Starlette applications."""
2+
3+
import logging
4+
import os
5+
6+
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH
7+
from opentelemetry import trace
8+
from starlette.applications import Starlette
9+
10+
from .otel import _capture_http_bodies, _is_text_content, _truncate_body
11+
12+
_logger = logging.getLogger(__name__)
13+
14+
15+
def _starlette_server_request_hook(span: trace.Span, scope: dict) -> None:
16+
"""Hook to capture Starlette request body in traces if enabled.
17+
18+
Note: This captures the body from the ASGI scope's cached body if available.
19+
It does not consume the request stream to avoid breaking request handling.
20+
"""
21+
22+
if not _capture_http_bodies:
23+
return
24+
25+
try:
26+
# Only process HTTP requests
27+
if scope.get("type") != "http":
28+
return
29+
30+
# Check if body is cached in scope (some middleware/frameworks cache it)
31+
# Don't try to read the stream directly as it would consume it
32+
if "body" in scope:
33+
body = scope["body"]
34+
if body:
35+
# Get content type from headers
36+
headers = dict(scope.get("headers", []))
37+
content_type = headers.get(b"content-type", b"").decode("latin1")
38+
39+
if _is_text_content(content_type):
40+
span.set_attribute("http.request.body", _truncate_body(body))
41+
except Exception:
42+
_logger.exception("Failed to capture Starlette request body in trace")
43+
44+
45+
def _starlette_client_request_hook(span: trace.Span, scope: dict, message: dict) -> None:
46+
"""Hook to capture Starlette client request body in traces if enabled."""
47+
# Import here to avoid circular dependency
48+
from .otel import _capture_http_bodies, _is_text_content, _truncate_body
49+
50+
if not _capture_http_bodies:
51+
return
52+
53+
try:
54+
# Capture body from the message if available and it's the body message
55+
if message.get("type") == "http.request" and "body" in message:
56+
body = message["body"]
57+
if body:
58+
# Get content type from scope headers
59+
headers = dict(scope.get("headers", []))
60+
content_type = headers.get(b"content-type", b"").decode("latin1")
61+
62+
if _is_text_content(content_type):
63+
span.set_attribute("http.request.body", _truncate_body(body))
64+
except Exception:
65+
_logger.exception("Failed to capture Starlette client request body in trace")
66+
67+
68+
def _starlette_client_response_hook(span: trace.Span, scope: dict, message: dict) -> None:
69+
"""Hook to capture Starlette client response body in traces if enabled."""
70+
71+
if not _capture_http_bodies:
72+
return
73+
74+
try:
75+
# Capture body from response message
76+
if message.get("type") == "http.response.body" and "body" in message:
77+
body = message["body"]
78+
if body:
79+
# We don't have easy access to response headers here
80+
# Could try to get from span attributes if set earlier
81+
span.set_attribute("http.response.body", _truncate_body(body))
82+
except Exception:
83+
_logger.exception("Failed to capture Starlette client response body in trace")
84+
85+
86+
def instrument_starlette_app(app: Starlette) -> None:
87+
"""Instrument a Starlette application with OpenTelemetry.
88+
89+
Args:
90+
app: The Starlette application to instrument
91+
92+
Note:
93+
Body logging is controlled by the enable_body_logging parameter passed to setup_otel().
94+
This should be called after setup_otel() has been called to set up the tracer provider.
95+
Body logging for Starlette is limited compared to HTTPX as it must avoid consuming
96+
request/response streams. Bodies are only captured when already buffered in the ASGI scope.
97+
"""
98+
99+
# env needs to be set here since _excluded_urls is initialized at module import time
100+
os.environ.setdefault("OTEL_PYTHON_STARLETTE_EXCLUDED_URLS", AGENT_CARD_WELL_KNOWN_PATH)
101+
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
102+
103+
StarletteInstrumentor().instrument_app(
104+
app,
105+
server_request_hook=_starlette_server_request_hook,
106+
client_request_hook=_starlette_client_request_hook,
107+
client_response_hook=_starlette_client_response_hook,
108+
)

0 commit comments

Comments
 (0)