Summary
get_fast_api_app() unconditionally installs two internal span exporters that retain trace data in process memory without any bound and with no way to disable them — including with web=False, i.e. the production serving path. In a long-lived process (Cloud Run with min-instances >= 1, GKE, any always-on deployment) this is a memory leak that grows linearly with traffic until the container is OOM-killed.
We measured ~196 KB retained per LLM turn in a production chatbot, causing an OOM kill (Out-of-memory event detected in container) every ~6 days, with a ~10-minute window of user-facing 503/504 errors before each kill.
Environment
- google-adk 2.7.0 (also verified present in earlier 2.x; the same classes exist since 1.x
fast_api.py)
- Python 3.13 · Cloud Run gen2 ·
min-instances: 1 · 2 GiB
- App built via
get_fast_api_app(agents_dir=..., web=False, session_service_uri="postgresql+asyncpg://...", otel_to_cloud=True)
Root cause
src/google/adk/cli/api_server.py (line numbers from 2.7.0):
ApiServerSpanExporter.export() — line 472:
self.trace_dict[attributes["gcp.vertex.agent.event_id"]] = attributes
For every call_llm / execute_tool span, the full attribute dict is stored — including gcp.vertex.agent.llm_request and gcp.vertex.agent.llm_response, i.e. the entire serialized prompt, history, and tool context. Entries are never evicted. With RAG-sized system prompts this is easily 50–200 KB per model call.
InMemoryExporter.export() — line 500:
self._spans.extend(spans)
Every span in the process is appended to an unbounded list. Its clear() is only called from the eval flow, which never runs in a production server.
- Both are installed unconditionally in
AdkWebServer.get_app() — line 1127:
_setup_telemetry(
otel_to_cloud=otel_to_cloud,
internal_exporters=[
export_lib.SimpleSpanProcessor(ApiServerSpanExporter(trace_dict)),
export_lib.SimpleSpanProcessor(memory_exporter),
],
)
get_fast_api_app() exposes no flag to disable them.
Why this looks like an oversight rather than a design choice
In 2.7 the debug endpoints that read these buffers (/debug/trace/{event_id}, /debug/trace/session/{session_id}) were moved to dev_server.py and are only mounted with web=True. The exporters that feed them, however, are still installed unconditionally in api_server.py. So with web=False a production process pays unbounded memory for buffers whose only readers are not even mounted. The reader was gated; the writer was not.
(Related report of the same "dev default leaks into the prod path" class: #3251, InMemoryMemoryService always instantiated in api_server.)
Production measurements
| Signal |
Measurement |
Rules out |
| Hours with <20 requests |
+0.0 MiB/h (n=29) |
background thread / slow GC |
| Day 1 · 1,882 LLM turns |
+351 MiB ⇒ ~196 KB/turn |
— |
| Day 2 · 775 LLM turns |
+148 MiB ⇒ ~200 KB/turn |
same per-turn cost at very different volume ⇒ linear retention |
| Container |
OOM-killed 4× in 60 days |
— |
Retention is purely traffic-proportional. Memory is perfectly flat during idle hours and never shrinks.
Minimal reproduction
from google.adk.cli.fast_api import get_fast_api_app
from opentelemetry import trace
app = get_fast_api_app(agents_dir="agents", web=False)
tp = trace.get_tracer_provider()
procs = tp._active_span_processor._span_processors
print([type(p.span_exporter).__name__ for p in procs])
# ['ApiServerSpanExporter', 'InMemoryExporter'] <- installed with web=False
# Any traffic now grows ApiServerSpanExporter.trace_dict and
# InMemoryExporter._spans for the lifetime of the process.
Expected behavior
One of (in order of preference):
- Don't install the internal exporters when
web=False — symmetric with the 2.7 gating of the debug endpoints that consume them.
- Bound the containers (
deque(maxlen=...) / LRU eviction on trace_dict), so retention is finite by construction even in dev.
- Expose a flag on
get_fast_api_app() (e.g. enable_debug_trace_buffers: bool) so servers can opt out.
Workaround we're using
We register an extra SpanProcessor after get_fast_api_app() that trims those same containers in place (FIFO) after ADK's processors have written. It works, but it depends on private structure (_active_span_processor._span_processors, trace_dict, _spans), so it will break silently-adjacent on any rename — hence this report.
Summary
get_fast_api_app()unconditionally installs two internal span exporters that retain trace data in process memory without any bound and with no way to disable them — including withweb=False, i.e. the production serving path. In a long-lived process (Cloud Run withmin-instances >= 1, GKE, any always-on deployment) this is a memory leak that grows linearly with traffic until the container is OOM-killed.We measured ~196 KB retained per LLM turn in a production chatbot, causing an OOM kill (
Out-of-memory event detected in container) every ~6 days, with a ~10-minute window of user-facing 503/504 errors before each kill.Environment
fast_api.py)min-instances: 1· 2 GiBget_fast_api_app(agents_dir=..., web=False, session_service_uri="postgresql+asyncpg://...", otel_to_cloud=True)Root cause
src/google/adk/cli/api_server.py(line numbers from 2.7.0):ApiServerSpanExporter.export()— line 472:For every
call_llm/execute_toolspan, the full attribute dict is stored — includinggcp.vertex.agent.llm_requestandgcp.vertex.agent.llm_response, i.e. the entire serialized prompt, history, and tool context. Entries are never evicted. With RAG-sized system prompts this is easily 50–200 KB per model call.InMemoryExporter.export()— line 500:Every span in the process is appended to an unbounded list. Its
clear()is only called from the eval flow, which never runs in a production server.AdkWebServer.get_app()— line 1127:get_fast_api_app()exposes no flag to disable them.Why this looks like an oversight rather than a design choice
In 2.7 the debug endpoints that read these buffers (
/debug/trace/{event_id},/debug/trace/session/{session_id}) were moved todev_server.pyand are only mounted withweb=True. The exporters that feed them, however, are still installed unconditionally inapi_server.py. So withweb=Falsea production process pays unbounded memory for buffers whose only readers are not even mounted. The reader was gated; the writer was not.(Related report of the same "dev default leaks into the prod path" class: #3251,
InMemoryMemoryServicealways instantiated inapi_server.)Production measurements
Retention is purely traffic-proportional. Memory is perfectly flat during idle hours and never shrinks.
Minimal reproduction
Expected behavior
One of (in order of preference):
web=False— symmetric with the 2.7 gating of the debug endpoints that consume them.deque(maxlen=...)/ LRU eviction ontrace_dict), so retention is finite by construction even in dev.get_fast_api_app()(e.g.enable_debug_trace_buffers: bool) so servers can opt out.Workaround we're using
We register an extra
SpanProcessorafterget_fast_api_app()that trims those same containers in place (FIFO) after ADK's processors have written. It works, but it depends on private structure (_active_span_processor._span_processors,trace_dict,_spans), so it will break silently-adjacent on any rename — hence this report.