Files
RKest 424ac79e8f feat(telemetry)!: default to experimental GenAI semconv, with ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=otel_semconv_1_36 rollback (#1635)
* feat(telemetry)!: default to experimental GenAI semconv, with ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=otel_semconv_1_36 rollback

ADK now emits the experimental OpenTelemetry GenAI semantic conventions by
default, following adk-python's schema v2: one
gen_ai.client.inference.operation.details event per model call,
gen_ai.tool.call.arguments/result on execute_tool gated on span content
capture, and an entrypoint invoke_workflow span. Message content is
recorded as structured attribute values on spans as well as events, where
adk-python records JSON strings on spans.

ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=otel_semconv_1_36 restores the previous
format, and otel_semconv_1_44 names the default. Values are
case-insensitive, and adk-python's 1 and 2 are accepted for them. No
otel_semconv_1_36 golden changes in this commit.

A model call and a runner invocation are traced only through
InstrumentGenerateContent and InstrumentInvocation, so the generate_content
span and its log records cannot drift apart.

Closes #1634

* fix(telemetry): address review comments on docs and number conversion

* fix(telemetry): describe the request as the model left it in the inference event

* refactor(telemetry): record request messages on the span and the event through operationDetails

* fix(telemetry): report dropped span attributes through the OpenTelemetry error handler

* fix(telemetry): add error.type to workflow node and summarizer-panic spans

* feat(telemetry): report a streamed inference as adk-python does in the default schema

* test(telemetry): have MockModel report STOP on unstreamed responses, as a real model does
2026-10-19 16:36:40 +02:00

73 lines
2.5 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package workflow
import (
"context"
"errors"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
"google.golang.org/adk/v2/agent"
"google.golang.org/adk/v2/internal/telemetry"
)
// nodeSpan is the single invoke_node span shared by both schedulers and
// the parallel worker.
type nodeSpan struct {
span trace.Span
}
// startNodeSpan begins an invoke_node span for n and returns a context
// carrying it. It is a noop span for Start and for nodes that emit their
// own span (EmitsOwnSpan, e.g. an agent node's invoke_agent), so callers
// can unconditionally defer end / call recordError.
func startNodeSpan(ctx agent.Context, n Node) (nodeSpan, agent.Context) {
if n == Start || n.Config().EmitsOwnSpan {
return nodeSpan{span: noop.Span{}}, ctx
}
spanCtx, span := telemetry.StartNodeSpan(ctx, ctx, telemetry.OperationNode{Node: n})
return nodeSpan{span: span}, ctx.WithAgentContext(spanCtx)
}
// recordError marks the span failed with recordErr, unless the outcome is
// expected control flow (see isNodeControlFlow). classifyErr picks the
// error to classify — pass the unwrapped cause when recordErr hides the
// sentinel (the dynamic scheduler wraps with "%w: %v", dropping
// context.Canceled); nil classifies on recordErr. Does not end the span.
func (s nodeSpan) recordError(recordErr, classifyErr error) {
if recordErr == nil {
return
}
if classifyErr == nil {
classifyErr = recordErr
}
if isNodeControlFlow(classifyErr) {
return
}
telemetry.RecordError(s.span, recordErr)
}
func (s nodeSpan) end() { s.span.End() }
// isNodeControlFlow reports whether err is expected control flow
// (cancellation or a HITL / wait-for-output pause), not a node failure.
// ErrNodeWaitingForOutput wraps ErrNodeInterrupted, so one check covers
// both pauses.
func isNodeControlFlow(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, ErrNodeInterrupted)
}