Skip to content

Commit 918752d

Browse files
committed
fix(telemetry): set error.type on spans that end in an error
When a model call or tool failed, OpenTelemetry marked every span the exception passed through as ERROR, but only execute_tool said which error it was. The invocation, invoke_workflow, invoke_agent, call_llm and generate_content spans had no error.type, even though the metrics for the same operations record it. Open these spans through a small helper that sets error.type from resolve_error_type() when an exception escapes, and set it where a workflow node hands its failure back as data. The functional goldens for the four error cases are re-recorded, and the generate_content divergence from the OTel instrumentor is now a value difference (429 vs ClientError), the same one already accepted for the duration metric. Fixes #7494
1 parent 3c31fa5 commit 918752d

11 files changed

Lines changed: 77 additions & 67 deletions

‎src/google/adk/flows/llm_flows/core/_model_call.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
from ....models.llm_request import LlmRequest
3434
from ....models.llm_response import LlmResponse
3535
from ....telemetry import _instrumentation
36+
from ....telemetry.tracing import start_as_current_span
3637
from ....telemetry.tracing import trace_call_llm
37-
from ....telemetry.tracing import tracer
3838
from ....utils._runner_utils import _with_caller_context
3939
from ....utils.context_utils import Aclosing
4040
from ._finalizer import has_meaningful_content
@@ -184,7 +184,7 @@ async def call_llm_async(
184184
caller_context = otel_context.get_current()
185185

186186
async def _call_llm_with_tracing() -> AsyncGenerator[LlmResponse, None]:
187-
with tracer.start_as_current_span('call_llm') as span:
187+
with start_as_current_span('call_llm') as span:
188188
# Runs before_model_callback inside the call_llm span so
189189
# plugins observe the same span as after/error callbacks.
190190
if response := await flow._handle_before_model_callback(

‎src/google/adk/telemetry/_instrumentation.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def record_invocation(
153153
Nothing; the span (if any) is active for the duration of the block.
154154
"""
155155
if resolve_schema_version() < SCHEMA_VERSION_SEMCONV_ALIGNED:
156-
with tracing.tracer.start_as_current_span("invocation"):
156+
with tracing.start_as_current_span("invocation"):
157157
yield
158158
return
159159

@@ -582,7 +582,7 @@ async def record_agent_invocation(
582582
context_api.set_value(_AGENT_INVOCATION_SCOPE_KEY, scope)
583583
)
584584
try:
585-
with tracing.tracer.start_as_current_span(span_name) as s:
585+
with tracing.start_as_current_span(span_name) as s:
586586
span = s
587587
tracing.trace_agent_invocation(span, agent, ctx)
588588
yield scope

‎src/google/adk/telemetry/node_tracing.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from opentelemetry import context as context_api
2929
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_CONVERSATION_ID
3030
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_OPERATION_NAME
31+
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
3132
from opentelemetry.trace import Span
3233
from opentelemetry.trace import Status
3334
from opentelemetry.trace import StatusCode
@@ -37,7 +38,8 @@
3738
from ..workflow._base_node import BaseNode
3839
from .context import TelemetryConfig
3940
from .tracing import _telemetry_config_from_invocation_context
40-
from .tracing import tracer
41+
from .tracing import resolve_error_type
42+
from .tracing import start_as_current_span
4143

4244
if TYPE_CHECKING:
4345
from opentelemetry.util.types import AttributeValue
@@ -153,7 +155,7 @@ def _invoke_node_span(
153155
context: Context, node: BaseNode
154156
) -> Iterator[TelemetryContext]:
155157
"""Opens an `invoke_node` span for a plain node."""
156-
with tracer.start_as_current_span(
158+
with start_as_current_span(
157159
f"invoke_node {node.name}",
158160
attributes={
159161
GEN_AI_OPERATION_NAME: "invoke_node",
@@ -247,7 +249,7 @@ def _use_invoke_workflow_span(
247249
recorded_error: BaseException | None = None
248250
try:
249251
with (
250-
tracer.start_as_current_span(
252+
start_as_current_span(
251253
name=span_name,
252254
attributes=attributes,
253255
context=otel_context,
@@ -272,6 +274,7 @@ def _use_invoke_workflow_span(
272274
recorded_error = get_recorded_error()
273275
if recorded_error is not None:
274276
span.record_exception(recorded_error)
277+
span.set_attribute(ERROR_TYPE, resolve_error_type(recorded_error))
275278
span.set_status(Status(StatusCode.ERROR, str(recorded_error)))
276279
finally:
277280
_metrics.record_workflow_invocation_duration(

‎src/google/adk/telemetry/tracing.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import logging
3333
import os
3434
import re
35+
from typing import Any
3536
from typing import Final
3637
from typing import TYPE_CHECKING
3738

@@ -211,6 +212,21 @@ def resolve_error_type(error: BaseException) -> str:
211212
return type(error).__name__
212213

213214

215+
@contextmanager
216+
def start_as_current_span(name: str, **kwargs: Any) -> Iterator[Span]:
217+
"""Opens a span with ``tracer`` that names the error escaping it, if any.
218+
219+
OpenTelemetry marks such a span ERROR on its own, but only ``error.type``
220+
says which error it was, and the metric for the same operation records it.
221+
"""
222+
with tracer.start_as_current_span(name, **kwargs) as span:
223+
try:
224+
yield span
225+
except Exception as e:
226+
span.set_attribute(ERROR_TYPE, resolve_error_type(e))
227+
raise
228+
229+
214230
def trace_agent_invocation(
215231
span: trace.Span, agent: BaseAgent, ctx: InvocationContext
216232
) -> None:
@@ -1120,7 +1136,7 @@ def _use_native_generate_content_span_stable_semconv(
11201136
) -> Iterator[GenerateContentSpan]:
11211137
telemetry_config = telemetry_config or TelemetryConfig()
11221138
system_name = _resolve_gen_ai_system_name(llm_request.model)
1123-
with tracer.start_as_current_span(
1139+
with start_as_current_span(
11241140
f"generate_content {llm_request.model or ''}".strip()
11251141
) as span:
11261142
span.set_attribute(GEN_AI_SYSTEM, system_name)
@@ -1174,7 +1190,7 @@ def _use_native_generate_content_span(
11741190
yield gc_span
11751191
return
11761192

1177-
with tracer.start_as_current_span(
1193+
with start_as_current_span(
11781194
f"generate_content {llm_request.model or ''}".strip()
11791195
) as span:
11801196
_set_common_generate_content_attributes(

‎tests/unittests/telemetry/functional/scenarios/telemetry_setup.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from typing import NamedTuple
2525

2626
from google.adk.telemetry import _metrics
27-
from google.adk.telemetry import node_tracing
2827
from google.adk.telemetry import tracing
2928
from opentelemetry.sdk._logs import LoggerProvider
3029
from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter
@@ -235,13 +234,12 @@ def install_telemetry(
235234
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
236235
real_tracer = tracer_provider.get_tracer(__name__)
237236

238-
for module in (tracing, node_tracing):
239-
monkeypatch.setattr(
240-
module.tracer,
241-
"start_as_current_span",
242-
real_tracer.start_as_current_span,
243-
)
244-
monkeypatch.setattr(module.tracer, "start_span", real_tracer.start_span)
237+
monkeypatch.setattr(
238+
tracing.tracer,
239+
"start_as_current_span",
240+
real_tracer.start_as_current_span,
241+
)
242+
monkeypatch.setattr(tracing.tracer, "start_span", real_tracer.start_span)
245243

246244
logger_provider = LoggerProvider()
247245
logger_provider.add_log_record_processor(

‎tests/unittests/telemetry/functional_divergences.json‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@
7575
"kind": "otel_bug",
7676
"reason": "The instrumentor reads `FunctionDeclaration.parameters`, which ADK leaves unset in favour of `parameters_json_schema`, so it advertises the tools by name and description but without their schema."
7777
},
78+
{
79+
"path": [
80+
"attributes",
81+
"error.type"
82+
],
83+
"example_native_instrumentation_value": "429",
84+
"example_otel_instrumentation_value": "ClientError",
85+
"kind": "desired_behavior",
86+
"reason": "ADK reports the provider's HTTP status code (`429`), the same value as on its duration metric; the instrumentor reports the exception class (`ClientError`), which `google.genai` collapses every 4xx into."
87+
},
7888
{
7989
"path": [
8090
"attributes",
@@ -99,18 +109,6 @@
99109
"kind": "otel_bug",
100110
"reason": "On a call that failed the instrumentor records an empty finish-reason list for a response it never got."
101111
},
102-
{
103-
"path": [
104-
"attributes",
105-
"error.type"
106-
],
107-
"example_native_instrumentation_value": {
108-
"missing_on_this_side": true
109-
},
110-
"example_otel_instrumentation_value": "ClientError",
111-
"kind": "adk_bug",
112-
"reason": "ADK reports a failed call on the enclosing spans and on the duration metric, but leaves `error.type` off the inference span itself."
113-
},
114112
{
115113
"path": [
116114
"attributes",

‎tests/unittests/telemetry/functional_goldens/agent/inference-error-resource-exhausted-schema-v1.json‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"root_span": {
33
"name": "invocation",
4-
"attributes": {},
4+
"attributes": {
5+
"error.type": "429"
6+
},
57
"status": "ERROR",
68
"children": [
79
{
@@ -10,13 +12,16 @@
1012
"gen_ai.operation.name": "invoke_agent",
1113
"gen_ai.agent.description": "A sample root agent.",
1214
"gen_ai.agent.name": "some_root_agent",
13-
"gen_ai.conversation.id": "PRESENT"
15+
"gen_ai.conversation.id": "PRESENT",
16+
"error.type": "429"
1417
},
1518
"status": "ERROR",
1619
"children": [
1720
{
1821
"name": "call_llm",
19-
"attributes": {},
22+
"attributes": {
23+
"error.type": "429"
24+
},
2025
"status": "ERROR",
2126
"children": [
2227
{
@@ -28,7 +33,8 @@
2833
"gen_ai.agent.name": "some_root_agent",
2934
"gen_ai.conversation.id": "PRESENT",
3035
"gcp.vertex.agent.event_id": "PRESENT",
31-
"gcp.vertex.agent.invocation_id": "PRESENT"
36+
"gcp.vertex.agent.invocation_id": "PRESENT",
37+
"error.type": "429"
3238
},
3339
"status": "ERROR",
3440
"children": [],

‎tests/unittests/telemetry/functional_goldens/agent/inference-error-resource-exhausted-schema-v2.json‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"attributes": {
55
"gen_ai.operation.name": "invoke_workflow",
66
"gen_ai.conversation.id": "PRESENT",
7-
"gen_ai.workflow.name": "some_root_agent"
7+
"gen_ai.workflow.name": "some_root_agent",
8+
"error.type": "429"
89
},
910
"status": "ERROR",
1011
"children": [
@@ -14,13 +15,16 @@
1415
"gen_ai.operation.name": "invoke_agent",
1516
"gen_ai.agent.description": "A sample root agent.",
1617
"gen_ai.agent.name": "some_root_agent",
17-
"gen_ai.conversation.id": "PRESENT"
18+
"gen_ai.conversation.id": "PRESENT",
19+
"error.type": "429"
1820
},
1921
"status": "ERROR",
2022
"children": [
2123
{
2224
"name": "call_llm",
23-
"attributes": {},
25+
"attributes": {
26+
"error.type": "429"
27+
},
2428
"status": "ERROR",
2529
"children": [
2630
{
@@ -32,7 +36,8 @@
3236
"gen_ai.agent.name": "some_root_agent",
3337
"gen_ai.conversation.id": "PRESENT",
3438
"gcp.vertex.agent.event_id": "PRESENT",
35-
"gcp.vertex.agent.invocation_id": "PRESENT"
39+
"gcp.vertex.agent.invocation_id": "PRESENT",
40+
"error.type": "429"
3641
},
3742
"status": "ERROR",
3843
"children": [],

‎tests/unittests/telemetry/functional_goldens/agent/inference-error-valueerror-schema-v2.json‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"attributes": {
55
"gen_ai.operation.name": "invoke_workflow",
66
"gen_ai.conversation.id": "PRESENT",
7-
"gen_ai.workflow.name": "some_root_agent"
7+
"gen_ai.workflow.name": "some_root_agent",
8+
"error.type": "ValueError"
89
},
910
"status": "ERROR",
1011
"children": [
@@ -14,13 +15,16 @@
1415
"gen_ai.operation.name": "invoke_agent",
1516
"gen_ai.agent.description": "A sample root agent.",
1617
"gen_ai.agent.name": "some_root_agent",
17-
"gen_ai.conversation.id": "PRESENT"
18+
"gen_ai.conversation.id": "PRESENT",
19+
"error.type": "ValueError"
1820
},
1921
"status": "ERROR",
2022
"children": [
2123
{
2224
"name": "call_llm",
23-
"attributes": {},
25+
"attributes": {
26+
"error.type": "ValueError"
27+
},
2428
"status": "ERROR",
2529
"children": [
2630
{
@@ -32,7 +36,8 @@
3236
"gen_ai.agent.name": "some_root_agent",
3337
"gen_ai.conversation.id": "PRESENT",
3438
"gcp.vertex.agent.event_id": "PRESENT",
35-
"gcp.vertex.agent.invocation_id": "PRESENT"
39+
"gcp.vertex.agent.invocation_id": "PRESENT",
40+
"error.type": "ValueError"
3641
},
3742
"status": "ERROR",
3843
"children": [],

‎tests/unittests/telemetry/functional_goldens/agent/tool-error-valueerror-schema-v2.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"attributes": {
55
"gen_ai.operation.name": "invoke_workflow",
66
"gen_ai.conversation.id": "PRESENT",
7-
"gen_ai.workflow.name": "some_root_agent"
7+
"gen_ai.workflow.name": "some_root_agent",
8+
"error.type": "ValueError"
89
},
910
"status": "ERROR",
1011
"children": [
@@ -14,7 +15,8 @@
1415
"gen_ai.operation.name": "invoke_agent",
1516
"gen_ai.agent.description": "A sample root agent.",
1617
"gen_ai.agent.name": "some_root_agent",
17-
"gen_ai.conversation.id": "PRESENT"
18+
"gen_ai.conversation.id": "PRESENT",
19+
"error.type": "ValueError"
1820
},
1921
"status": "ERROR",
2022
"children": [

0 commit comments

Comments
 (0)