Skip to content

Commit 2509eff

Browse files
authored
Google ADK Tool Instrumentation (#1768)
* Google ADK Tool Instrumentation * Expand Megalinter ignored files * Apply suggestions from code review * Gate subcomponent attrs to local tools * Renaming according to code review
1 parent fef8ce4 commit 2509eff

5 files changed

Lines changed: 386 additions & 0 deletions

File tree

newrelic/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3188,6 +3188,11 @@ def _process_module_builtin_defaults():
31883188
_process_module_definition(
31893189
"google.adk.agents.llm_agent", "newrelic.hooks.mlmodel_googleadk", "instrument_googleadk_agents_llm_agent"
31903190
)
3191+
_process_module_definition(
3192+
"google.adk.flows.llm_flows.functions",
3193+
"newrelic.hooks.mlmodel_googleadk",
3194+
"instrument_googleadk_flows_llm_flows_functions",
3195+
)
31913196
_process_module_definition(
31923197
"strands.agent.agent", "newrelic.hooks.mlmodel_strands", "instrument_strands_agent_agent"
31933198
)

newrelic/hooks/mlmodel_googleadk.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@
2323
from newrelic.common.llm_utils import AsyncLLMStreamProxy, _get_llm_metadata
2424
from newrelic.common.object_wrapper import wrap_function_wrapper
2525
from newrelic.common.package_version_utils import get_package_version
26+
from newrelic.common.signature import bind_args
2627
from newrelic.core.config import global_settings
2728

2829
_logger = logging.getLogger(__name__)
2930
GOOGLEADK_VERSION = get_package_version("google-adk")
3031

3132
RECORD_EVENTS_FAILURE_LOG_MESSAGE = "Exception occurred in Google ADK instrumentation: Failed to record LLM events. Please report this issue to New Relic Support."
3233
AGENT_EVENT_FAILURE_LOG_MESSAGE = "Exception occurred in Google ADK instrumentation: Failed to record agent data. Please report this issue to New Relic Support."
34+
TOOL_EXTRACTOR_FAILURE_LOG_MESSAGE = "Exception occurred in Google ADK instrumentation: Failed to extract tool information. If the issue persists, report this issue to New Relic support.\n"
3335

3436

3537
def wrap_llm_agent__run_async_impl(wrapped, instance, args, kwargs):
@@ -145,6 +147,147 @@ def _construct_base_agent_event_dict(agent_name, agent_id, transaction, linking_
145147
return agent_event_dict
146148

147149

150+
async def wrap__execute_single_function_call_async(wrapped, instance, args, kwargs):
151+
transaction = current_transaction()
152+
if not transaction:
153+
return await wrapped(*args, **kwargs)
154+
155+
settings = transaction.settings or global_settings()
156+
if not settings.ai_monitoring.enabled:
157+
return await wrapped(*args, **kwargs)
158+
159+
transaction.add_ml_model_info("GoogleADK", GOOGLEADK_VERSION)
160+
transaction._add_agent_attribute("llm", True)
161+
162+
tool_name = "tool"
163+
run_id = ""
164+
tool_input = None
165+
agent_name = "agent"
166+
is_local_tool = False
167+
try:
168+
bound_args = bind_args(wrapped, args, kwargs)
169+
function_call = bound_args.get("function_call")
170+
agent = bound_args.get("agent")
171+
tools_dict = bound_args.get("tools_dict")
172+
if function_call is not None:
173+
tool_name = getattr(function_call, "name", "tool") or "tool"
174+
run_id = getattr(function_call, "id", "") or ""
175+
tool_input = getattr(function_call, "args", None)
176+
if tools_dict is not None:
177+
from google.adk.tools.function_tool import FunctionTool
178+
179+
is_local_tool = isinstance(tools_dict.get(tool_name), FunctionTool)
180+
if agent is not None:
181+
agent_name = getattr(agent, "name", "agent") or "agent"
182+
except Exception:
183+
_logger.warning(TOOL_EXTRACTOR_FAILURE_LOG_MESSAGE, exc_info=True)
184+
185+
function_trace_name = f"execute_single_function_call_async/{tool_name}"
186+
187+
ft = FunctionTrace(name=function_trace_name, group="Llm/tool/GoogleADK")
188+
ft.__enter__()
189+
if is_local_tool:
190+
agentic_subcomponent_data = {"type": "APM-AI_TOOL", "name": tool_name}
191+
ft._add_agent_attribute("subcomponent", json.dumps(agentic_subcomponent_data))
192+
linking_metadata = get_trace_linking_metadata()
193+
tool_id = str(uuid.uuid4())
194+
195+
try:
196+
tool_output = await wrapped(*args, **kwargs)
197+
except Exception:
198+
ft.notice_error(attributes={"tool_id": tool_id})
199+
ft.__exit__(*sys.exc_info())
200+
try:
201+
tool_event_dict = _construct_base_tool_event_dict(
202+
tool_name=tool_name,
203+
tool_id=tool_id,
204+
run_id=run_id,
205+
tool_input=tool_input,
206+
tool_output=None,
207+
agent_name=agent_name,
208+
error=True,
209+
transaction=transaction,
210+
linking_metadata=linking_metadata,
211+
)
212+
if tool_event_dict:
213+
tool_event_dict["duration"] = ft.duration * 1000
214+
transaction.record_custom_event("LlmTool", tool_event_dict)
215+
except Exception:
216+
_logger.warning(RECORD_EVENTS_FAILURE_LOG_MESSAGE, exc_info=True)
217+
raise
218+
219+
ft.__exit__(None, None, None)
220+
try:
221+
response_dict = _extract_tool_response_dict(tool_output)
222+
tool_event_dict = _construct_base_tool_event_dict(
223+
tool_name=tool_name,
224+
tool_id=tool_id,
225+
run_id=run_id,
226+
tool_input=tool_input,
227+
tool_output=response_dict,
228+
agent_name=agent_name,
229+
error=False,
230+
transaction=transaction,
231+
linking_metadata=linking_metadata,
232+
)
233+
if tool_event_dict:
234+
tool_event_dict["duration"] = ft.duration * 1000
235+
transaction.record_custom_event("LlmTool", tool_event_dict)
236+
except Exception:
237+
_logger.warning(RECORD_EVENTS_FAILURE_LOG_MESSAGE, exc_info=True)
238+
239+
return tool_output
240+
241+
242+
def _extract_tool_response_dict(tool_output):
243+
"""Return the dict at content.parts[*].function_response.response, or None."""
244+
try:
245+
parts = tool_output.content.parts
246+
for part in parts:
247+
function_response = getattr(part, "function_response", None)
248+
if function_response is not None:
249+
return getattr(function_response, "response", None)
250+
except (AttributeError, TypeError):
251+
pass
252+
return None
253+
254+
255+
def _construct_base_tool_event_dict(
256+
tool_name, tool_id, run_id, tool_input, tool_output, agent_name, error, transaction, linking_metadata
257+
):
258+
try:
259+
settings = transaction.settings or global_settings()
260+
261+
tool_event_dict = {
262+
"id": tool_id,
263+
"run_id": run_id,
264+
"name": tool_name,
265+
"span_id": linking_metadata.get("span.id"),
266+
"trace_id": linking_metadata.get("trace.id"),
267+
"agent_name": agent_name,
268+
"vendor": "google_adk",
269+
"ingest_source": "Python",
270+
}
271+
if error:
272+
tool_event_dict["error"] = True
273+
274+
if settings.ai_monitoring.record_content.enabled:
275+
tool_event_dict["input"] = str(tool_input) if tool_input else None
276+
tool_event_dict["output"] = str(tool_output) if tool_output else None
277+
278+
tool_event_dict.update(_get_llm_metadata(transaction))
279+
except Exception:
280+
tool_event_dict = {}
281+
_logger.warning(RECORD_EVENTS_FAILURE_LOG_MESSAGE, exc_info=True)
282+
283+
return tool_event_dict
284+
285+
148286
def instrument_googleadk_agents_llm_agent(module):
149287
if hasattr(module, "LlmAgent") and hasattr(module.LlmAgent, "_run_async_impl"):
150288
wrap_function_wrapper(module, "LlmAgent._run_async_impl", wrap_llm_agent__run_async_impl)
289+
290+
291+
def instrument_googleadk_flows_llm_flows_functions(module):
292+
if hasattr(module, "_execute_single_function_call_async"):
293+
wrap_function_wrapper(module, "_execute_single_function_call_async", wrap__execute_single_function_call_async)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import functools
16+
17+
from _test_agent import AGENT_NAME
18+
from google.adk.tools import FunctionTool
19+
20+
TOOL_NAME = "get_capital"
21+
22+
23+
def get_capital(country: str) -> dict:
24+
"""Return the capital of a country."""
25+
capitals = {"France": "Paris", "Japan": "Tokyo"}
26+
return {"output": capitals.get(country, "Unknown")}
27+
28+
29+
@functools.wraps(get_capital) # Make function names match
30+
def _raising_capital(country: str) -> dict:
31+
"""Return the capital of a country."""
32+
raise ValueError("intentional tool failure")
33+
34+
35+
get_capital_tool = FunctionTool(func=get_capital)
36+
raising_tool = FunctionTool(func=_raising_capital)
37+
38+
EXPECTED_TOOL_INPUT_STR = "{'country': 'France'}"
39+
EXPECTED_TOOL_OUTPUT_STR = "{'output': 'Paris'}"
40+
41+
42+
def tool_recorded_event(record_content: bool):
43+
base = {
44+
"id": None,
45+
"run_id": None,
46+
"name": TOOL_NAME,
47+
"span_id": None,
48+
"trace_id": "trace-id",
49+
"agent_name": AGENT_NAME,
50+
"vendor": "google_adk",
51+
"ingest_source": "Python",
52+
"duration": None,
53+
}
54+
if record_content:
55+
base["input"] = EXPECTED_TOOL_INPUT_STR
56+
base["output"] = EXPECTED_TOOL_OUTPUT_STR
57+
return [({"type": "LlmTool"}, base)]
58+
59+
60+
def tool_recorded_event_error(record_content: bool):
61+
base = {
62+
"id": None,
63+
"run_id": None,
64+
"name": TOOL_NAME,
65+
"span_id": None,
66+
"trace_id": "trace-id",
67+
"agent_name": AGENT_NAME,
68+
"vendor": "google_adk",
69+
"ingest_source": "Python",
70+
"duration": None,
71+
"error": True,
72+
}
73+
if record_content:
74+
base["input"] = EXPECTED_TOOL_INPUT_STR
75+
return [({"type": "LlmTool"}, base)]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2010 New Relic, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from _test_agent import AGENT_NAME, PROMPT, agent_recorded_event, build_agent
16+
from _test_tools import TOOL_NAME, get_capital_tool, tool_recorded_event
17+
from testing_support.fixtures import dt_enabled, reset_core_stats_engine, validate_attributes
18+
from testing_support.ml_testing_utils import (
19+
disabled_ai_monitoring_record_content_settings,
20+
disabled_ai_monitoring_settings,
21+
events_with_context_attrs,
22+
)
23+
from testing_support.validators.validate_custom_event import validate_custom_event_count
24+
from testing_support.validators.validate_custom_events import validate_custom_events
25+
from testing_support.validators.validate_span_events import validate_span_events
26+
from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics
27+
28+
from newrelic.api.background_task import background_task
29+
from newrelic.api.llm_custom_attributes import WithLlmCustomAttributes
30+
from newrelic.hooks.mlmodel_googleadk import GOOGLEADK_VERSION
31+
32+
EXPECTED_AGENT_METRIC = (f"Llm/agent/GoogleADK/run_async/{AGENT_NAME}", 1)
33+
EXPECTED_TOOL_METRIC = (f"Llm/tool/GoogleADK/execute_single_function_call_async/{TOOL_NAME}", 1)
34+
EXPECTED_SUPPORTABILITY_METRIC = (f"Supportability/Python/ML/GoogleADK/{GOOGLEADK_VERSION}", 1)
35+
36+
37+
def _validate_events(events):
38+
assert len(events) == 3
39+
assert events[0].content.parts[0].function_call.name == "get_capital"
40+
assert events[1].content.parts[0].function_response.response["output"] == "Paris"
41+
assert events[2].content.parts[0].text == "Paris"
42+
43+
44+
@dt_enabled
45+
@reset_core_stats_engine()
46+
@validate_custom_events(events_with_context_attrs(agent_recorded_event + tool_recorded_event(record_content=True)))
47+
@validate_custom_event_count(count=8) # LlmAgent, LlmTool, 2x (Summary, Input, Output) for the two LLM calls.
48+
@validate_transaction_metrics(
49+
"test_tools:test_tool",
50+
scoped_metrics=[EXPECTED_AGENT_METRIC, EXPECTED_TOOL_METRIC],
51+
rollup_metrics=[EXPECTED_AGENT_METRIC, EXPECTED_TOOL_METRIC],
52+
custom_metrics=[EXPECTED_SUPPORTABILITY_METRIC],
53+
background_task=True,
54+
)
55+
@validate_attributes("agent", ["llm"])
56+
@validate_span_events(count=1, exact_agents={"subcomponent": '{"type": "APM-AI_AGENT", "name": "my_agent"}'})
57+
@validate_span_events(count=1, exact_agents={"subcomponent": f'{{"type": "APM-AI_TOOL", "name": "{TOOL_NAME}"}}'})
58+
@background_task()
59+
def test_tool(exercise_agent, set_trace_info):
60+
set_trace_info()
61+
agent = build_agent(tools=[get_capital_tool])
62+
with WithLlmCustomAttributes({"context": "attr"}):
63+
events = exercise_agent(agent, PROMPT)
64+
65+
_validate_events(events)
66+
67+
68+
@dt_enabled
69+
@reset_core_stats_engine()
70+
@disabled_ai_monitoring_record_content_settings
71+
@validate_custom_events(agent_recorded_event + tool_recorded_event(record_content=False))
72+
@validate_custom_event_count(count=8) # LlmAgent, LlmTool, 2x (Summary, Input, Output) for the two LLM calls.
73+
@validate_transaction_metrics(
74+
"test_tools:test_tool_no_content",
75+
scoped_metrics=[EXPECTED_AGENT_METRIC, EXPECTED_TOOL_METRIC],
76+
rollup_metrics=[EXPECTED_AGENT_METRIC, EXPECTED_TOOL_METRIC],
77+
background_task=True,
78+
)
79+
@validate_attributes("agent", ["llm"])
80+
@background_task()
81+
def test_tool_no_content(exercise_agent, set_trace_info):
82+
set_trace_info()
83+
agent = build_agent(tools=[get_capital_tool])
84+
events = exercise_agent(agent, PROMPT)
85+
86+
_validate_events(events)
87+
88+
89+
@dt_enabled
90+
@reset_core_stats_engine()
91+
@disabled_ai_monitoring_settings
92+
@validate_custom_event_count(count=0)
93+
@validate_transaction_metrics("test_tools:test_tool_disabled_ai_monitoring", background_task=True)
94+
@background_task()
95+
def test_tool_disabled_ai_monitoring(exercise_agent, set_trace_info):
96+
set_trace_info()
97+
agent = build_agent(tools=[get_capital_tool])
98+
events = exercise_agent(agent, PROMPT)
99+
100+
_validate_events(events)
101+
102+
103+
@reset_core_stats_engine()
104+
@validate_custom_event_count(count=0)
105+
def test_tool_outside_transaction(exercise_agent, set_trace_info):
106+
set_trace_info()
107+
agent = build_agent(tools=[get_capital_tool])
108+
events = exercise_agent(agent, PROMPT)
109+
110+
_validate_events(events)

0 commit comments

Comments
 (0)