Skip to content

Commit bd397dd

Browse files
Dwij1704dot-agi
andauthored
Implement Google ADK Instrumentation for AgentOps (#1009)
* Implement Google ADK Instrumentation for AgentOps * Fix import compatibility for NotRequired in instrumentation module by adding a fallback to typing_extensions. This ensures broader compatibility across Python versions. * Update instrumentation_dependencies to require 'google-adk' instead of 'google-genai' for improved package management. * Refactor LLM attribute extraction to handle system instructions as role messages. Updated message processing to include proper indexing and role normalization for contents, enhancing clarity and consistency in attribute assignment. * Refactor attribute assignment in Google ADK instrumentation to use constants for span attributes. This change enhances code clarity and maintainability by replacing string literals with defined constants for agent, LLM, and tool attributes. * Enhance Google ADK instrumentation by adding 'type' parameter to span creation for agent, LLM, and tool contexts. This improves clarity in span categorization and aligns with the existing attribute assignment structure. * Refactor Google ADK instrumentation to remove 'type' parameter from span creation for agent, LLM, and tool contexts. This change simplifies span initialization while maintaining clarity in span categorization through attribute assignment. * refactor version code into `__init__.py` --------- Co-authored-by: Pratyush Shukla <[email protected]>
1 parent d7acb59 commit bd397dd

File tree

4 files changed

+957
-45
lines changed

4 files changed

+957
-45
lines changed

agentops/instrumentation/__init__.py

Lines changed: 118 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
"""
1414

1515
from typing import Optional, Set, TypedDict
16+
17+
try:
18+
from typing import NotRequired
19+
except ImportError:
20+
from typing_extensions import NotRequired
1621
from types import ModuleType
1722
from dataclasses import dataclass
1823
import importlib
@@ -36,8 +41,11 @@
3641

3742
def _is_package_instrumented(package_name: str) -> bool:
3843
"""Check if a package is already instrumented by looking at active instrumentors."""
44+
# Handle package.module names by converting dots to underscores for comparison
45+
normalized_name = package_name.replace(".", "_").lower()
3946
return any(
40-
instrumentor.__class__.__name__.lower().startswith(package_name.lower())
47+
instrumentor.__class__.__name__.lower().startswith(normalized_name)
48+
or instrumentor.__class__.__name__.lower().startswith(package_name.split(".")[-1].lower())
4149
for instrumentor in _active_instrumentors
4250
)
4351

@@ -65,17 +73,14 @@ def _should_instrument_package(package_name: str) -> bool:
6573
if package_name in AGENTIC_LIBRARIES:
6674
_uninstrument_providers()
6775
_has_agentic_library = True
68-
logger.debug(f"Uninstrumented all providers due to agentic library {package_name} detection")
6976
return True
7077

7178
# Skip providers if an agentic library is already instrumented
7279
if package_name in PROVIDERS and _has_agentic_library:
73-
logger.debug(f"Skipping provider {package_name} instrumentation as an agentic library is already instrumented")
7480
return False
7581

7682
# Skip if already instrumented
7783
if _is_package_instrumented(package_name):
78-
logger.debug(f"Package {package_name} is already instrumented")
7984
return False
8085

8186
return True
@@ -102,36 +107,62 @@ def _import_monitor(name: str, globals_dict=None, locals_dict=None, fromlist=(),
102107
Monitor imports and instrument packages as they are imported.
103108
This replaces the built-in import function to intercept package imports.
104109
"""
105-
global _instrumenting_packages
106-
root = name.split(".", 1)[0]
110+
global _instrumenting_packages, _has_agentic_library
107111

108-
# Skip providers if an agentic library is already instrumented
109-
if _has_agentic_library and root in PROVIDERS:
112+
# If an agentic library is already instrumented, skip all further instrumentation
113+
if _has_agentic_library:
110114
return _original_builtins_import(name, globals_dict, locals_dict, fromlist, level)
111115

112-
# Check if this is a package we should instrument
113-
if (
114-
root in TARGET_PACKAGES
115-
and root not in _instrumenting_packages
116-
and not _is_package_instrumented(root) # Check if already instrumented before adding
117-
):
118-
logger.debug(f"Detected import of {root}")
119-
_instrumenting_packages.add(root)
120-
try:
121-
_perform_instrumentation(root)
122-
except Exception as e:
123-
logger.error(f"Error instrumenting {root}: {str(e)}")
124-
finally:
125-
_instrumenting_packages.discard(root)
126-
127-
return _original_builtins_import(name, globals_dict, locals_dict, fromlist, level)
116+
# First, do the actual import
117+
module = _original_builtins_import(name, globals_dict, locals_dict, fromlist, level)
118+
119+
# Check for exact matches first (handles package.module like google.adk)
120+
packages_to_check = set()
121+
122+
# Check the imported module itself
123+
if name in TARGET_PACKAGES:
124+
packages_to_check.add(name)
125+
else:
126+
# Check if any target package is a prefix of the import name
127+
for target in TARGET_PACKAGES:
128+
if name.startswith(target + ".") or name == target:
129+
packages_to_check.add(target)
130+
131+
# For "from X import Y" style imports, also check submodules
132+
if fromlist:
133+
for item in fromlist:
134+
full_name = f"{name}.{item}"
135+
if full_name in TARGET_PACKAGES:
136+
packages_to_check.add(full_name)
137+
else:
138+
# Check if any target package matches this submodule
139+
for target in TARGET_PACKAGES:
140+
if full_name == target or full_name.startswith(target + "."):
141+
packages_to_check.add(target)
142+
143+
# Instrument all matching packages
144+
for package_to_check in packages_to_check:
145+
if package_to_check not in _instrumenting_packages and not _is_package_instrumented(package_to_check):
146+
_instrumenting_packages.add(package_to_check)
147+
try:
148+
_perform_instrumentation(package_to_check)
149+
# If we just instrumented an agentic library, stop
150+
if _has_agentic_library:
151+
break
152+
except Exception as e:
153+
logger.error(f"Error instrumenting {package_to_check}: {str(e)}")
154+
finally:
155+
_instrumenting_packages.discard(package_to_check)
156+
157+
return module
128158

129159

130160
# Define the structure for instrumentor configurations
131161
class InstrumentorConfig(TypedDict):
132162
module_name: str
133163
class_name: str
134164
min_version: str
165+
package_name: NotRequired[str] # Optional: actual pip package name if different from module
135166

136167

137168
# Configuration for supported LLM providers
@@ -146,16 +177,17 @@ class InstrumentorConfig(TypedDict):
146177
"class_name": "AnthropicInstrumentor",
147178
"min_version": "0.32.0",
148179
},
149-
"google.genai": {
150-
"module_name": "agentops.instrumentation.google_generativeai",
151-
"class_name": "GoogleGenerativeAIInstrumentor",
152-
"min_version": "0.1.0",
153-
},
154180
"ibm_watsonx_ai": {
155181
"module_name": "agentops.instrumentation.ibm_watsonx_ai",
156182
"class_name": "IBMWatsonXInstrumentor",
157183
"min_version": "0.1.0",
158184
},
185+
"google.genai": {
186+
"module_name": "agentops.instrumentation.google_generativeai",
187+
"class_name": "GoogleGenerativeAIInstrumentor",
188+
"min_version": "0.1.0",
189+
"package_name": "google-genai", # Actual pip package name
190+
},
159191
}
160192

161193
# Configuration for supported agentic libraries
@@ -171,6 +203,11 @@ class InstrumentorConfig(TypedDict):
171203
"class_name": "OpenAIAgentsInstrumentor",
172204
"min_version": "0.0.1",
173205
},
206+
"google.adk": {
207+
"module_name": "agentops.instrumentation.google_adk",
208+
"class_name": "GoogleADKInstrumentor",
209+
"min_version": "0.1.0",
210+
},
174211
}
175212

176213
# Combine all target packages for monitoring
@@ -190,6 +227,7 @@ class InstrumentorLoader:
190227
module_name: str
191228
class_name: str
192229
min_version: str
230+
package_name: Optional[str] = None # Optional: actual pip package name
193231

194232
@property
195233
def module(self) -> ModuleType:
@@ -200,7 +238,11 @@ def module(self) -> ModuleType:
200238
def should_activate(self) -> bool:
201239
"""Check if the package is available and meets version requirements."""
202240
try:
203-
provider_name = self.module_name.split(".")[-1]
241+
# Use explicit package_name if provided, otherwise derive from module_name
242+
if self.package_name:
243+
provider_name = self.package_name
244+
else:
245+
provider_name = self.module_name.split(".")[-1]
204246
module_version = version(provider_name)
205247
return module_version is not None and Version(module_version) >= parse(self.min_version)
206248
except ImportError:
@@ -233,24 +275,44 @@ def instrument_all():
233275
# Check if active_instrumentors is empty, as a proxy for not started.
234276
if not _active_instrumentors:
235277
builtins.__import__ = _import_monitor
236-
global _instrumenting_packages
278+
global _instrumenting_packages, _has_agentic_library
279+
280+
# If an agentic library is already instrumented, don't instrument anything else
281+
if _has_agentic_library:
282+
return
283+
237284
for name in list(sys.modules.keys()):
285+
# Stop if an agentic library gets instrumented during the loop
286+
if _has_agentic_library:
287+
break
288+
238289
module = sys.modules.get(name)
239290
if not isinstance(module, ModuleType):
240291
continue
241292

242-
root = name.split(".", 1)[0]
243-
if _has_agentic_library and root in PROVIDERS:
244-
continue
245-
246-
if root in TARGET_PACKAGES and root not in _instrumenting_packages and not _is_package_instrumented(root):
247-
_instrumenting_packages.add(root)
293+
# Check for exact matches first (handles package.module like google.adk)
294+
package_to_check = None
295+
if name in TARGET_PACKAGES:
296+
package_to_check = name
297+
else:
298+
# Check if any target package is a prefix of the module name
299+
for target in TARGET_PACKAGES:
300+
if name.startswith(target + ".") or name == target:
301+
package_to_check = target
302+
break
303+
304+
if (
305+
package_to_check
306+
and package_to_check not in _instrumenting_packages
307+
and not _is_package_instrumented(package_to_check)
308+
):
309+
_instrumenting_packages.add(package_to_check)
248310
try:
249-
_perform_instrumentation(root)
311+
_perform_instrumentation(package_to_check)
250312
except Exception as e:
251-
logger.error(f"Error instrumenting {root}: {str(e)}")
313+
logger.error(f"Error instrumenting {package_to_check}: {str(e)}")
252314
finally:
253-
_instrumenting_packages.discard(root)
315+
_instrumenting_packages.discard(package_to_check)
254316

255317

256318
def uninstrument_all():
@@ -269,8 +331,19 @@ def get_active_libraries() -> set[str]:
269331
Get all actively used libraries in the current execution context.
270332
Returns a set of package names that are currently imported and being monitored.
271333
"""
272-
return {
273-
name.split(".")[0]
274-
for name, module in sys.modules.items()
275-
if isinstance(module, ModuleType) and name.split(".")[0] in TARGET_PACKAGES
276-
}
334+
active_libs = set()
335+
for name, module in sys.modules.items():
336+
if not isinstance(module, ModuleType):
337+
continue
338+
339+
# Check for exact matches first
340+
if name in TARGET_PACKAGES:
341+
active_libs.add(name)
342+
else:
343+
# Check if any target package is a prefix of the module name
344+
for target in TARGET_PACKAGES:
345+
if name.startswith(target + ".") or name == target:
346+
active_libs.add(target)
347+
break
348+
349+
return active_libs
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Google ADK Instrumentation for AgentOps
2+
3+
This module provides instrumentation for Google's Agent Development Kit (ADK),
4+
capturing agent execution, LLM calls, tool calls, and other ADK-specific events.
5+
"""
6+
7+
from importlib.metadata import version, PackageNotFoundError
8+
9+
try:
10+
__version__ = version("google-adk")
11+
except PackageNotFoundError:
12+
__version__ = "0.0.0"
13+
14+
LIBRARY_NAME = "agentops.instrumentation.google_adk"
15+
LIBRARY_VERSION = __version__
16+
17+
from agentops.instrumentation.google_adk.instrumentor import GoogleADKInstrumentor # noqa: E402
18+
from agentops.instrumentation.google_adk import patch # noqa: E402
19+
20+
__all__ = ["LIBRARY_NAME", "LIBRARY_VERSION", "GoogleADKInstrumentor", "patch"]
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Google ADK Instrumentation for AgentOps
2+
3+
This module provides instrumentation for Google's Agent Development Kit (ADK).
4+
It uses a patching approach to:
5+
1. Disable ADK's built-in telemetry to prevent duplicate spans
6+
2. Create AgentOps spans that mirror ADK's telemetry structure
7+
3. Extract and properly index LLM messages and tool calls
8+
"""
9+
10+
from typing import Collection
11+
from opentelemetry.trace import get_tracer
12+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
13+
from opentelemetry.metrics import get_meter
14+
15+
from agentops.logging import logger
16+
from agentops.instrumentation.google_adk import LIBRARY_NAME, LIBRARY_VERSION
17+
from agentops.instrumentation.google_adk.patch import patch_adk, unpatch_adk
18+
from agentops.semconv import Meters
19+
20+
21+
class GoogleADKInstrumentor(BaseInstrumentor):
22+
"""An instrumentor for Google Agent Development Kit (ADK).
23+
24+
This instrumentor patches Google ADK to:
25+
- Prevent ADK from creating its own telemetry spans
26+
- Create AgentOps spans for agent runs, LLM calls, and tool calls
27+
- Properly extract and index message content and tool interactions
28+
"""
29+
30+
def instrumentation_dependencies(self) -> Collection[str]:
31+
"""Return packages required for instrumentation."""
32+
return ["google-adk >= 0.1.0"]
33+
34+
def _instrument(self, **kwargs):
35+
"""Instrument the Google ADK.
36+
37+
This method:
38+
1. Disables ADK's built-in telemetry
39+
2. Patches key ADK methods to create AgentOps spans
40+
3. Sets up metrics for tracking token usage and operation duration
41+
"""
42+
# Set up tracer and meter
43+
tracer_provider = kwargs.get("tracer_provider")
44+
tracer = get_tracer(LIBRARY_NAME, LIBRARY_VERSION, tracer_provider)
45+
46+
meter_provider = kwargs.get("meter_provider")
47+
meter = get_meter(LIBRARY_NAME, LIBRARY_VERSION, meter_provider)
48+
49+
# Create metrics
50+
meter.create_histogram(
51+
name=Meters.LLM_TOKEN_USAGE,
52+
unit="token",
53+
description="Measures number of input and output tokens used with Google ADK",
54+
)
55+
56+
meter.create_histogram(
57+
name=Meters.LLM_OPERATION_DURATION,
58+
unit="s",
59+
description="Google ADK operation duration",
60+
)
61+
62+
meter.create_counter(
63+
name=Meters.LLM_COMPLETIONS_EXCEPTIONS,
64+
unit="time",
65+
description="Number of exceptions occurred during Google ADK operations",
66+
)
67+
68+
# Apply patches
69+
patch_adk(tracer)
70+
logger.info("Google ADK instrumentation enabled")
71+
72+
def _uninstrument(self, **kwargs):
73+
"""Remove instrumentation from Google ADK.
74+
75+
This method removes all patches and restores ADK's original behavior.
76+
"""
77+
unpatch_adk()
78+
logger.info("Google ADK instrumentation disabled")

0 commit comments

Comments
 (0)