Skip to content

Commit c22fb91

Browse files
feat(sdk): add optional traceprovider parameter (#260)
## Summary - The built-in OTEL observability sink always created its own `TracerProvider`, exporter, and span processor. Applications that already run OTEL (e.g. Google ADK via `GoogleADKInstrumentor`) ended up with two disconnected pipelines, splitting application and control spans across separate export requests. - Adds an optional `otel_tracer_provider` param to `agent_control.init()` so the built-in `otel` sink can reuse an application-owned or globally-registered SDK provider instead of always creating its own. ## Scope - **User-facing/API changes:** - New optional `otel_tracer_provider` kwarg on `agent_control.init()` / `init_observability()`. - Applied only when `observability_sink_name="otel"`; ignored for `default`, `registered`, and other named sinks. - Provider resolution order: explicit param → valid globally-registered OTEL SDK `TracerProvider` (via `trace.get_tracer_provider()`) → Agent Control-owned provider (existing behavior). - The default OTEL proxy/no-op provider is never treated as a configured provider. - README documents a Google ADK shared-provider example. - **Internal changes:** - `otel_sink.py`: `create_otel_control_event_sink()` accepts a `tracer_provider` kwarg, tracks `provider_owned`, and only attaches an Agent Control exporter/`BatchSpanProcessor` when it creates the provider itself. OTLP exporter import is now lazy and skipped entirely on the external-provider path. - `observability.py`: provider is threaded through a runtime-only global (`_configured_otel_tracer_provider`), not the JSON-typed `observability_sink_config`. Changing the explicit provider on reinitialization invalidates only the cached OTEL sink, leaving other sink caches untouched. - `OTELControlEventSink.close()` flushes external providers but no longer shuts them down; Agent Control-created providers are still shut down as before. - **Out of scope:** Orbit, ingest-service, Galileo Python SDK, and customer application code were not touched. No changes to `default`, `registered`, or other named custom sink behavior. ## Risk and Rollout - **Risk level:** low — the new parameter defaults to `None` and only affects behavior when `observability_sink_name="otel"`; all other sink paths are unchanged. - **Rollback plan:** revert the commit; no data migrations, schema changes, or server-side changes are involved (Python SDK only). ## Testing - [x] Added or updated automated tests — `test_otel_sink.py` (explicit-provider precedence, provider-owned vs. external, proxy/no-op rejection, no duplicate processor, trace/parent correlation, lifecycle/shutdown ownership, reinitialization invalidation) and `test_init_validation.py` (runtime-only path, not leaked into `sink_config`). - [x] Ran equivalent of `make check`: `uv run pytest` (644 passed, 1 pre-existing unrelated failure requiring a live server, confirmed failing on the prior commit too), `uv run mypy src/agent_control` (clean), `uv run ruff check src/` (clean, matches the `make lint` target). - [x] Manually verified behavior — traced through provider resolution order, shutdown/ownership branches, and cache invalidation logic against each acceptance criterion in the spec; no regressions found in default/registered/custom sink paths. ## Checklist - [x] Linked issue/spec (SAO-16152 — reuse existing OTEL provider in Agent Control Python SDK) - [x] Updated docs/examples for user-facing changes (README: Google ADK shared-provider example) - [ ] Included any required follow-up tasks — none identified
1 parent 541f872 commit c22fb91

6 files changed

Lines changed: 516 additions & 22 deletions

File tree

‎sdks/python/README.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,38 @@ async def search_database(query: str) -> str:
2424
```
2525

2626
Controls are defined centrally and enforced automatically at runtime. See [Python SDK Documentation](https://docs.agentcontrol.dev/sdk/python-sdk) for complete reference.
27+
28+
## Sharing an OpenTelemetry provider with Google ADK
29+
30+
When Google ADK and Agent Control should export through the same OpenTelemetry
31+
pipeline, configure one SDK `TracerProvider` and pass it to both components:
32+
33+
```python
34+
import agent_control
35+
from opentelemetry import trace
36+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
37+
from openinference.instrumentation.google_adk import GoogleADKInstrumentor
38+
from opentelemetry.sdk.resources import Resource
39+
from opentelemetry.sdk.trace import TracerProvider
40+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
41+
42+
resource = Resource.create({"service.name": "my-google-adk-agent"})
43+
provider = TracerProvider(resource=resource)
44+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
45+
trace.set_tracer_provider(provider)
46+
47+
GoogleADKInstrumentor().instrument(tracer_provider=provider)
48+
agent_control.init(
49+
agent_name="my-google-adk-agent",
50+
observability_enabled=True,
51+
observability_sink_name="otel",
52+
observability_sink_config={"enabled": True},
53+
otel_tracer_provider=provider,
54+
)
55+
```
56+
57+
Agent Control reuses this provider without adding an exporter or span processor.
58+
It force-flushes the provider during shutdown but leaves provider shutdown to the
59+
application. If no provider is passed, the OTEL sink first checks for a globally
60+
registered SDK provider, then falls back to its existing Agent Control-owned OTLP
61+
pipeline.

‎sdks/python/src/agent_control/__init__.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ async def handle_input(user_message: str) -> str:
3838
)
3939
"""
4040

41+
from __future__ import annotations
42+
4143
from importlib.metadata import PackageNotFoundError, version
4244

4345
try:
@@ -51,7 +53,7 @@ async def handle_input(user_message: str) -> str:
5153
from collections.abc import Callable, Coroutine
5254
from dataclasses import dataclass
5355
from datetime import UTC, datetime
54-
from typing import Any, Literal, TypeVar
56+
from typing import TYPE_CHECKING, Any, Literal, TypeVar
5557

5658
import httpx
5759
from agent_control_models import (
@@ -123,6 +125,9 @@ async def handle_input(user_message: str) -> str:
123125
)
124126
from .validation import ensure_agent_name
125127

128+
if TYPE_CHECKING:
129+
from opentelemetry.sdk.trace import TracerProvider
130+
126131
# Module logger
127132
logger = get_logger(__name__)
128133

@@ -460,6 +465,7 @@ def init(
460465
target_type: str | None = None,
461466
target_id: str | None = None,
462467
runtime_token_header: str | None = None,
468+
otel_tracer_provider: TracerProvider | None = None,
463469
**kwargs: object
464470
) -> Agent:
465471
"""
@@ -514,6 +520,10 @@ def init(
514520
X-Agent-Control-Runtime-Token) when the server runs behind a gateway
515521
that reserves Authorization for its own identity JWT. The server must
516522
be configured to read the same header.
523+
otel_tracer_provider: Optional application-owned OpenTelemetry SDK provider.
524+
Used only when observability_sink_name is ``"otel"``. If omitted,
525+
the OTEL sink reuses a globally registered SDK provider when available,
526+
otherwise it creates and owns a provider as before.
517527
**kwargs: Additional metadata to store with the agent
518528
519529
Returns:
@@ -738,6 +748,7 @@ def run_in_thread() -> None:
738748
enabled=observability_enabled,
739749
sink_name=observability_sink_name,
740750
sink_config=observability_sink_config,
751+
otel_tracer_provider=otel_tracer_provider,
741752
)
742753
if batcher:
743754
logger.info("Observability enabled")

‎sdks/python/src/agent_control/observability.py‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383

8484
if TYPE_CHECKING:
8585
from agent_control_models import ControlExecutionEvent
86+
from opentelemetry.sdk.trace import TracerProvider
8687

8788
from .otel_sink import (
8889
OTEL_CONTROL_EVENT_SINK_NAME,
@@ -833,6 +834,8 @@ def get_stats(self) -> dict:
833834
)
834835
_configured_named_event_sink: ControlEventSink | None = None
835836
_configured_named_event_sink_selection: ControlEventSinkSelection | None = None
837+
_configured_otel_tracer_provider: TracerProvider | None = None
838+
_configured_named_event_sink_provider: TracerProvider | None = None
836839
_configured_named_event_sink_lock = threading.Lock()
837840
_used_custom_event_sinks: list[ControlEventSink] = []
838841
_used_custom_event_sinks_lock = threading.Lock()
@@ -842,7 +845,15 @@ def _register_builtin_control_event_sink_factories() -> None:
842845
"""Ensure built-in named sink factories are available."""
843846
_named_event_sink_factories.register(
844847
OTEL_CONTROL_EVENT_SINK_NAME,
845-
create_otel_control_event_sink,
848+
_create_configured_otel_control_event_sink,
849+
)
850+
851+
852+
def _create_configured_otel_control_event_sink(config: JSONObject) -> BaseControlEventSink:
853+
"""Create the OTEL sink with its runtime-only provider configuration."""
854+
return create_otel_control_event_sink(
855+
config,
856+
tracer_provider=_configured_otel_tracer_provider,
846857
)
847858

848859

@@ -976,14 +987,21 @@ def _get_or_create_named_control_event_sink(
976987
selection: ControlEventSinkSelection,
977988
) -> ControlEventSink | None:
978989
"""Resolve and cache a named configured sink."""
979-
global _configured_named_event_sink, _configured_named_event_sink_selection
990+
global _configured_named_event_sink, _configured_named_event_sink_provider
991+
global _configured_named_event_sink_selection
980992

981993
previous_sink: ControlEventSink | None = None
994+
selected_provider = (
995+
_configured_otel_tracer_provider
996+
if selection.name == OTEL_CONTROL_EVENT_SINK_NAME
997+
else None
998+
)
982999

9831000
with _configured_named_event_sink_lock:
9841001
if (
9851002
_configured_named_event_sink is not None
9861003
and _configured_named_event_sink_selection == selection
1004+
and _configured_named_event_sink_provider is selected_provider
9871005
):
9881006
return _configured_named_event_sink
9891007

@@ -1004,6 +1022,7 @@ def _get_or_create_named_control_event_sink(
10041022
previous_sink = _configured_named_event_sink
10051023
_configured_named_event_sink = sink
10061024
_configured_named_event_sink_selection = selection.model_copy(deep=True)
1025+
_configured_named_event_sink_provider = selected_provider
10071026

10081027
if previous_sink is not None:
10091028
_shutdown_custom_control_event_sink(previous_sink)
@@ -1051,18 +1070,41 @@ def _shutdown_built_in_event_sink() -> None:
10511070

10521071
def _shutdown_configured_named_event_sink() -> None:
10531072
"""Stop and clear the cached configured named sink if it is active."""
1054-
global _configured_named_event_sink, _configured_named_event_sink_selection
1073+
global _configured_named_event_sink, _configured_named_event_sink_provider
1074+
global _configured_named_event_sink_selection
10551075

10561076
configured_named_sink: ControlEventSink | None = None
10571077
with _configured_named_event_sink_lock:
10581078
configured_named_sink = _configured_named_event_sink
10591079
_configured_named_event_sink = None
10601080
_configured_named_event_sink_selection = None
1081+
_configured_named_event_sink_provider = None
10611082

10621083
if configured_named_sink is not None:
10631084
_shutdown_custom_control_event_sink(configured_named_sink)
10641085

10651086

1087+
def _invalidate_cached_otel_sink() -> None:
1088+
"""Clear and shut down only the cached built-in OTEL sink, if present."""
1089+
global _configured_named_event_sink, _configured_named_event_sink_provider
1090+
global _configured_named_event_sink_selection
1091+
1092+
configured_otel_sink: ControlEventSink | None = None
1093+
with _configured_named_event_sink_lock:
1094+
if (
1095+
_configured_named_event_sink_selection is None
1096+
or _configured_named_event_sink_selection.name != OTEL_CONTROL_EVENT_SINK_NAME
1097+
):
1098+
return
1099+
configured_otel_sink = _configured_named_event_sink
1100+
_configured_named_event_sink = None
1101+
_configured_named_event_sink_selection = None
1102+
_configured_named_event_sink_provider = None
1103+
1104+
if configured_otel_sink is not None:
1105+
_shutdown_custom_control_event_sink(configured_otel_sink)
1106+
1107+
10661108
def _shutdown_custom_control_event_sink(sink: ControlEventSink) -> None:
10671109
"""Flush and close a custom sink when it exposes lifecycle hooks."""
10681110
flush = getattr(sink, "flush", None)
@@ -1109,6 +1151,7 @@ def init_observability(
11091151
enabled: bool | None = None,
11101152
sink_name: str | None = None,
11111153
sink_config: JSONObject | None = None,
1154+
otel_tracer_provider: TracerProvider | None = None,
11121155
) -> EventBatcher | None:
11131156
"""
11141157
Initialize observability system.
@@ -1122,11 +1165,12 @@ def init_observability(
11221165
enabled: Override AGENT_CONTROL_OBSERVABILITY_ENABLED
11231166
sink_name: Override AGENT_CONTROL_OBSERVABILITY_SINK_NAME
11241167
sink_config: Override AGENT_CONTROL_OBSERVABILITY_SINK_CONFIG
1168+
otel_tracer_provider: Runtime-only provider for the built-in OTEL sink
11251169
11261170
Returns:
11271171
EventBatcher instance if enabled, None otherwise
11281172
"""
1129-
global _batcher, _event_sink
1173+
global _batcher, _configured_otel_tracer_provider, _event_sink
11301174

11311175
settings_updates: dict[str, object] = {}
11321176
current_settings = get_settings()
@@ -1144,6 +1188,17 @@ def init_observability(
11441188
if settings_updates:
11451189
configure_settings(**settings_updates)
11461190

1191+
selected_sink_name = get_settings().observability_sink_name
1192+
selected_otel_tracer_provider = (
1193+
otel_tracer_provider
1194+
if selected_sink_name == OTEL_CONTROL_EVENT_SINK_NAME
1195+
else None
1196+
)
1197+
provider_changed = selected_otel_tracer_provider is not _configured_otel_tracer_provider
1198+
_configured_otel_tracer_provider = selected_otel_tracer_provider
1199+
if provider_changed:
1200+
_invalidate_cached_otel_sink()
1201+
11471202
is_enabled = get_settings().observability_enabled
11481203

11491204
if not is_enabled:

0 commit comments

Comments
 (0)