From fa6939add9ea31ba088ccef1e7eb79a4252fcca6 Mon Sep 17 00:00:00 2001 From: Diego Braga Date: Sat, 4 Jul 2026 15:44:06 +0200 Subject: [PATCH 1/2] feat(adk): expose gen_ai.client.token.usage Prometheus metric Add GenAI token-usage instrumentation to the ADK agent using the native Prometheus client library (github.com/prometheus/client_golang), matching kagent's existing Go metrics approach on the controller side. - pkg/telemetry/metrics.go: a gen_ai_client_token_usage histogram (Prometheus name for the OTel semconv metric gen_ai.client.token.usage) with labels gen_ai_token_type (input/output), gen_ai_request_model, gen_ai_provider_name and gen_ai_operation_name, following GenAI semantic conventions (semconv 1.40.0). Exposed via MetricsHandler. - pkg/a2a/server/server.go: serve the metrics at /metrics on the agent's A2A mux (excluded from request tracing). - pkg/a2a/executor.go: record token counts from the A2A event loop where UsageMetadata is surfaced (input = PromptTokenCount, output = CandidatesTokenCount + ThoughtsTokenCount). - cmd/main.go / pkg/config: resolve the request model and provider labels from the agent config. Replaces the earlier OTLP MeterProvider approach per maintainer review, keeping OTel-semconv naming. prometheus/client_golang is already a dependency; no new direct requirements. Refs: #2148 Signed-off-by: Diego Braga --- go/adk/cmd/main.go | 14 ++++++ go/adk/pkg/a2a/executor.go | 17 +++++++ go/adk/pkg/a2a/server/server.go | 6 ++- go/adk/pkg/config/config_usage.go | 7 +++ go/adk/pkg/telemetry/metrics.go | 66 ++++++++++++++++++++++++++++ go/adk/pkg/telemetry/metrics_test.go | 63 ++++++++++++++++++++++++++ go/go.mod | 1 + 7 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 go/adk/pkg/telemetry/metrics.go create mode 100644 go/adk/pkg/telemetry/metrics_test.go diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index cc5a61bbf5..69db6ba8bd 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -19,6 +19,7 @@ import ( runnerpkg "github.com/kagent-dev/kagent/go/adk/pkg/runner" "github.com/kagent-dev/kagent/go/adk/pkg/session" "github.com/kagent-dev/kagent/go/adk/pkg/telemetry" + "github.com/kagent-dev/kagent/go/api/adk" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) @@ -185,6 +186,7 @@ func main() { } stream := agentConfig.GetStream() + modelName, providerName := resolveModelLabels(agentConfig) executor := a2a.NewKAgentExecutor(a2a.KAgentExecutorConfig{ RunnerConfig: runnerConfig, SubagentSessionIDs: subagentSessionIDs, @@ -192,6 +194,8 @@ func main() { Stream: stream, AppName: appName, Logger: logger, + ModelName: modelName, + ProviderName: providerName, }) // Build the agent card. @@ -231,6 +235,16 @@ func main() { } } +// resolveModelLabels derives the gen_ai.request.model / gen_ai.provider.name +// labels for token-usage metrics from the agent config. Returns empty strings +// when no model is configured; the metric simply omits those attributes. +func resolveModelLabels(agentConfig *adk.AgentConfig) (model, provider string) { + if agentConfig == nil || agentConfig.Model == nil { + return "", "" + } + return config.ModelName(agentConfig.Model), agentConfig.Model.GetType() +} + func deriveAppName(kagentName, kagentNamespace string, agentCard *a2atype.AgentCard, logger logr.Logger) string { if kagentNamespace != "" && kagentName != "" { namespace := strings.ReplaceAll(kagentNamespace, "-", "_") diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index e92fc7df1c..cbb70bab04 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -37,6 +37,11 @@ type KAgentExecutorConfig struct { AppName string SkillsDirectory string Logger logr.Logger + // ModelName and ProviderName label GenAI token-usage metrics + // (gen_ai.request.model / gen_ai.provider.name). Both may be empty, in + // which case the corresponding metric attributes are omitted. + ModelName string + ProviderName string } // KAgentExecutor implements a2asrv.AgentExecutor @@ -48,6 +53,8 @@ type KAgentExecutor struct { appName string skillsDirectory string logger logr.Logger + modelName string + providerName string } var _ a2asrv.AgentExecutor = (*KAgentExecutor)(nil) @@ -69,6 +76,8 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) *KAgentExecutor { appName: cfg.AppName, skillsDirectory: skillsDir, logger: cfg.Logger.WithName("kagent-executor"), + modelName: cfg.ModelName, + providerName: cfg.ProviderName, } } @@ -288,6 +297,14 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestCont // Build per-event metadata (inherits baseMeta + adds invocation_id, usage etc.). eventMeta := buildEventMeta(baseMeta, adkEvent) + // Record GenAI token usage on the events that carry it. Output combines + // candidate + reasoning tokens. + if um := adkEvent.UsageMetadata; um != nil { + input := int64(um.PromptTokenCount) + output := int64(um.CandidatesTokenCount) + int64(um.ThoughtsTokenCount) + telemetry.RecordTokenUsage(e.modelName, e.providerName, input, output) + } + // Convert GenAI parts → A2A parts (with kagent stamping). if adkEvent.Content == nil || len(adkEvent.Content.Parts) == 0 { // Events with no content carry metadata only; still track invocationID/usage. diff --git a/go/adk/pkg/a2a/server/server.go b/go/adk/pkg/a2a/server/server.go index 9f2bb9bcfa..0a9c82c5f5 100644 --- a/go/adk/pkg/a2a/server/server.go +++ b/go/adk/pkg/a2a/server/server.go @@ -14,6 +14,8 @@ import ( "github.com/a2aproject/a2a-go/a2asrv" "github.com/go-logr/logr" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + + "github.com/kagent-dev/kagent/go/adk/pkg/telemetry" ) // ServerConfig holds configuration for the A2A server. @@ -38,6 +40,8 @@ func NewA2AServer(agentCard a2atype.AgentCard, executor a2asrv.AgentExecutor, lo mux := http.NewServeMux() RegisterHealthEndpoints(mux) + // Expose GenAI token-usage (and standard Go/process) metrics for scraping. + mux.Handle("/metrics", telemetry.MetricsHandler()) mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(&agentCard)) mux.Handle("/", jsonrpcHandler) // Wrap the whole server mux to enable trace context extraction and an inbound @@ -50,7 +54,7 @@ func NewA2AServer(agentCard a2atype.AgentCard, executor a2asrv.AgentExecutor, lo }), otelhttp.WithFilter(func(r *http.Request) bool { switch r.URL.Path { - case "/health", "/healthz", a2asrv.WellKnownAgentCardPath: + case "/health", "/healthz", "/metrics", a2asrv.WellKnownAgentCardPath: return false default: return true diff --git a/go/adk/pkg/config/config_usage.go b/go/adk/pkg/config/config_usage.go index 126400261e..145907603d 100644 --- a/go/adk/pkg/config/config_usage.go +++ b/go/adk/pkg/config/config_usage.go @@ -129,6 +129,13 @@ func GetAgentConfigSummary(config *adk.AgentConfig) string { return summary } +// ModelName returns the configured model identifier (e.g. "gpt-4o", +// "claude-3-5-sonnet"), or "unknown" for an unrecognized model type. It is the +// exported form of getModelName, used to label GenAI telemetry. +func ModelName(m adk.Model) string { + return getModelName(m) +} + func getModelName(m adk.Model) string { switch m := m.(type) { case *adk.OpenAI: diff --git a/go/adk/pkg/telemetry/metrics.go b/go/adk/pkg/telemetry/metrics.go new file mode 100644 index 0000000000..69e1925a96 --- /dev/null +++ b/go/adk/pkg/telemetry/metrics.go @@ -0,0 +1,66 @@ +package telemetry + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// GenAI token-usage instrumentation using the native Prometheus client library. +// +// Metric and attribute names follow the OpenTelemetry GenAI semantic +// conventions (semconv 1.40.0), mapped to Prometheus naming (dots -> underscores): +// - metric gen_ai.client.token.usage -> gen_ai_client_token_usage +// - attrs gen_ai.token.type, gen_ai.request.model, gen_ai.provider.name, +// gen_ai.operation.name +// +// https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/ +const ( + metricGenAIClientTokenUsage = "gen_ai_client_token_usage" + + labelGenAITokenType = "gen_ai_token_type" + labelGenAIRequestModel = "gen_ai_request_model" + labelGenAIProviderName = "gen_ai_provider_name" + labelGenAIOperationName = "gen_ai_operation_name" + + tokenTypeInput = "input" + tokenTypeOutput = "output" + + // operationChat is the gen_ai.operation.name for the chat-completion calls + // that produce the token usage recorded here. + operationChat = "chat" +) + +// tokenUsage is the gen_ai.client.token.usage histogram, registered on the +// default Prometheus registry so it is served by MetricsHandler alongside the +// standard Go/process collectors. +var tokenUsage = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: metricGenAIClientTokenUsage, + Help: "Measures the number of input and output tokens used by GenAI requests.", + // Token-count buckets spanning short prompts to large-context requests. + Buckets: []float64{1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576}, + }, + []string{labelGenAITokenType, labelGenAIRequestModel, labelGenAIProviderName, labelGenAIOperationName}, +) + +// MetricsHandler returns an http.Handler that serves the agent's Prometheus +// metrics, intended to be mounted at /metrics for scraping. +func MetricsHandler() http.Handler { + return promhttp.Handler() +} + +// RecordTokenUsage records input/output token counts on the +// gen_ai.client.token.usage histogram, labelled with the request model, provider +// and (implicit) chat operation. Zero counts are skipped; empty model/provider +// are recorded as empty label values. +func RecordTokenUsage(model, provider string, inputTokens, outputTokens int64) { + if inputTokens > 0 { + tokenUsage.WithLabelValues(tokenTypeInput, model, provider, operationChat).Observe(float64(inputTokens)) + } + if outputTokens > 0 { + tokenUsage.WithLabelValues(tokenTypeOutput, model, provider, operationChat).Observe(float64(outputTokens)) + } +} diff --git a/go/adk/pkg/telemetry/metrics_test.go b/go/adk/pkg/telemetry/metrics_test.go new file mode 100644 index 0000000000..f763954d92 --- /dev/null +++ b/go/adk/pkg/telemetry/metrics_test.go @@ -0,0 +1,63 @@ +package telemetry + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// TestRecordTokenUsage_RecordsHistogram verifies input/output token counts are +// recorded as two separate series on the gen_ai_client_token_usage histogram. +func TestRecordTokenUsage_RecordsHistogram(t *testing.T) { + tokenUsage.Reset() + + RecordTokenUsage("gpt-4o", "openai", 100, 42) + + // One series per token type (input, output). + if got := testutil.CollectAndCount(tokenUsage); got != 2 { + t.Fatalf("expected 2 histogram series (input+output), got %d", got) + } +} + +// TestRecordTokenUsage_SkipsZero verifies zero counts produce no series. +func TestRecordTokenUsage_SkipsZero(t *testing.T) { + tokenUsage.Reset() + + RecordTokenUsage("gpt-4o", "openai", 0, 0) + + if got := testutil.CollectAndCount(tokenUsage); got != 0 { + t.Fatalf("expected no series for zero token counts, got %d", got) + } +} + +// TestMetricsHandler_ServesTokenUsage verifies the /metrics handler exposes the +// recorded gen_ai_client_token_usage series with the expected semconv labels in +// the Prometheus text format. +func TestMetricsHandler_ServesTokenUsage(t *testing.T) { + tokenUsage.Reset() + RecordTokenUsage("claude-3-5-sonnet", "anthropic", 10, 5) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + MetricsHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "gen_ai_client_token_usage_count", + `gen_ai_token_type="input"`, + `gen_ai_token_type="output"`, + `gen_ai_request_model="claude-3-5-sonnet"`, + `gen_ai_provider_name="anthropic"`, + `gen_ai_operation_name="chat"`, + } { + if !strings.Contains(body, want) { + t.Errorf("metrics output missing %q", want) + } + } +} diff --git a/go/go.mod b/go/go.mod index 10b447672b..7f85021324 100644 --- a/go/go.mod +++ b/go/go.mod @@ -272,6 +272,7 @@ require ( github.com/klauspost/compress v1.18.6 // indirect github.com/kulti/thelper v0.7.1 // indirect github.com/kunwardeep/paralleltest v1.0.15 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lasiar/canonicalheader v1.1.2 // indirect github.com/ldez/exptostd v0.4.5 // indirect github.com/ldez/gomoddirectives v0.8.0 // indirect From 41f6a652154d97f6f36474a51b0cb224352c2f2d Mon Sep 17 00:00:00 2001 From: Diego Braga Date: Tue, 7 Jul 2026 14:53:24 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(adk):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20semconv=20provider=20enum,=20response.model,=20error.type,?= =?UTF-8?q?=20scrape=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @krisztianfekete's review on #2149: - provider label mapped to semconv well-known gen_ai.provider.name values via SemconvProviderName (gemini_vertex_ai->gcp.vertex_ai, bedrock->aws.bedrock, azure_openai->azure.ai.openai, etc.); unknown types pass through. - add gen_ai.response.model (falls back to request model) and error.type labels. - use the semconv-recommended histogram bucket layout. - guard against double-counting streaming partial events (record once per LLM call, not per chunk) + test asserting observations == LLM calls. - controller: annotate Go-runtime agent pods for Prometheus discovery (prometheus.io/scrape|port|path) so the /metrics endpoint is scrapeable. - docs/genai-token-metrics.md: what is emitted, how to scrape, how to verify. The OTel Prometheus->OTLP push bridge is left as a follow-up per the review. Refs: #2148 Signed-off-by: Diego Braga --- docs/genai-token-metrics.md | 88 ++++++++++++++++++ go/adk/cmd/main.go | 2 +- go/adk/pkg/a2a/executor.go | 30 ++++-- go/adk/pkg/a2a/executor_metrics_test.go | 56 +++++++++++ go/adk/pkg/telemetry/metrics.go | 92 +++++++++++++++---- go/adk/pkg/telemetry/metrics_test.go | 80 ++++++++++++---- .../translator/agent/manifest_builder.go | 8 ++ .../translator/agent/runtime_test.go | 56 +++++++++++ 8 files changed, 371 insertions(+), 41 deletions(-) create mode 100644 docs/genai-token-metrics.md create mode 100644 go/adk/pkg/a2a/executor_metrics_test.go diff --git a/docs/genai-token-metrics.md b/docs/genai-token-metrics.md new file mode 100644 index 0000000000..97c20f1c79 --- /dev/null +++ b/docs/genai-token-metrics.md @@ -0,0 +1,88 @@ +# GenAI token-usage metrics + +kagent's **Go ADK** agent runtime records the OpenTelemetry GenAI-semconv metric +[`gen_ai.client.token.usage`](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage) +using the native Prometheus client library and exposes it for scraping. It lets you graph and alert +on token spend per model / provider without parsing traces. + +## What is emitted + +A Prometheus histogram, served at **`/metrics`** on the agent's HTTP port: + +| Prometheus name | OTel semconv | Notes | +| --- | --- | --- | +| `gen_ai_client_token_usage` | `gen_ai.client.token.usage` | histogram, semconv-recommended buckets | + +Labels (semconv attributes, dots → underscores): + +| Label | Values | +| --- | --- | +| `gen_ai_token_type` | `input`, `output` (output = candidate + reasoning tokens) | +| `gen_ai_operation_name` | `chat` | +| `gen_ai_provider_name` | well-known value, e.g. `openai`, `anthropic`, `gcp.vertex_ai`, `aws.bedrock`, `azure.ai.openai` | +| `gen_ai_request_model` | configured model, e.g. `gpt-4o` | +| `gen_ai_response_model` | model the provider served (falls back to request model) | +| `error_type` | set on failed requests; empty otherwise | + +One observation is recorded per LLM call (streaming partial chunks are not double-counted). + +## Configuration + +- **Runtime**: available on Declarative agents with `runtime: go`. (The Python runtime is not yet + instrumented.) +- **No flag to enable**: the `/metrics` endpoint is always served, and the controller adds Prometheus + pod annotations to Go-runtime agent Deployments automatically: + + ```yaml + metadata: + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "" + prometheus.io/path: "/metrics" + ``` + +### Scraping it + +Any Prometheus-compatible scraper that honors pod annotations will pick agents up. With an +OpenTelemetry Collector, add a `prometheus` receiver job with pod discovery: + +```yaml +receivers: + prometheus: + config: + scrape_configs: + - job_name: kagent-agents + kubernetes_sd_configs: [{ role: pod }] + relabel_configs: + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] + regex: "true" + action: keep + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] + target_label: __metrics_path__ + - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port] + regex: ([^:]+)(?::\d+)?;(\d+) + replacement: $$1:$$2 + target_label: __address__ +``` + +## Verifying + +```bash +# 1. exec into a Go-runtime agent pod and curl its metrics endpoint +kubectl exec -- wget -qO- localhost:/metrics | grep gen_ai_client_token_usage + +# 2. after chatting with the agent you should see series like: +# gen_ai_client_token_usage_count{gen_ai_token_type="input",gen_ai_provider_name="gcp.vertex_ai", +# gen_ai_request_model="gemini-2.5-flash",gen_ai_response_model="gemini-2.5-flash", +# gen_ai_operation_name="chat",error_type=""} 1 +``` + + + +## Follow-ups + +- Optional OTLP **push** (in addition to the scrape endpoint) via the OpenTelemetry + [Prometheus→OTLP bridge](https://pkg.go.dev/go.opentelemetry.io/contrib/bridges/prometheus), for + environments that push to an OTLP collector rather than scrape. +- Python ADK runtime instrumentation. diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index 69db6ba8bd..2799135ab2 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -242,7 +242,7 @@ func resolveModelLabels(agentConfig *adk.AgentConfig) (model, provider string) { if agentConfig == nil || agentConfig.Model == nil { return "", "" } - return config.ModelName(agentConfig.Model), agentConfig.Model.GetType() + return config.ModelName(agentConfig.Model), telemetry.SemconvProviderName(agentConfig.Model.GetType()) } func deriveAppName(kagentName, kagentNamespace string, agentCard *a2atype.AgentCard, logger logr.Logger) string { diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index cbb70bab04..fae75746a2 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -20,6 +20,7 @@ import ( adkagent "google.golang.org/adk/agent" "google.golang.org/adk/runner" "google.golang.org/adk/server/adka2a" //nolint:staticcheck // kagent still uses a2a-go v1; this ADK package is the compatibility adapter. + adksession "google.golang.org/adk/session" ) const ( @@ -81,6 +82,26 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) *KAgentExecutor { } } +// recordTokenUsage records GenAI token usage for a single agent event on the +// gen_ai.client.token.usage histogram. Partial (streaming) events are skipped: +// a streamed LLM call emits many Partial chunks but usage is reported once on +// the aggregated non-partial event, so this counts one observation per LLM +// call, not per stream chunk. Output combines candidate + reasoning tokens. +func (e *KAgentExecutor) recordTokenUsage(adkEvent *adksession.Event) { + um := adkEvent.UsageMetadata + if um == nil || adkEvent.Partial { + return + } + telemetry.RecordTokenUsage(telemetry.TokenUsage{ + RequestModel: e.modelName, + ResponseModel: adkEvent.ModelVersion, + Provider: e.providerName, + ErrorType: adkEvent.ErrorCode, + InputTokens: int64(um.PromptTokenCount), + OutputTokens: int64(um.CandidatesTokenCount) + int64(um.ThoughtsTokenCount), + }) +} + // UserIDCallInterceptor returns an a2asrv.CallInterceptor that extracts the // x-user-id HTTP header from the incoming request metadata and sets it as the // authenticated user on the CallContext. @@ -297,13 +318,8 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestCont // Build per-event metadata (inherits baseMeta + adds invocation_id, usage etc.). eventMeta := buildEventMeta(baseMeta, adkEvent) - // Record GenAI token usage on the events that carry it. Output combines - // candidate + reasoning tokens. - if um := adkEvent.UsageMetadata; um != nil { - input := int64(um.PromptTokenCount) - output := int64(um.CandidatesTokenCount) + int64(um.ThoughtsTokenCount) - telemetry.RecordTokenUsage(e.modelName, e.providerName, input, output) - } + // Record GenAI token usage for this event. + e.recordTokenUsage(adkEvent) // Convert GenAI parts → A2A parts (with kagent stamping). if adkEvent.Content == nil || len(adkEvent.Content.Parts) == 0 { diff --git a/go/adk/pkg/a2a/executor_metrics_test.go b/go/adk/pkg/a2a/executor_metrics_test.go new file mode 100644 index 0000000000..0be6fb203e --- /dev/null +++ b/go/adk/pkg/a2a/executor_metrics_test.go @@ -0,0 +1,56 @@ +package a2a + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + adkmodel "google.golang.org/adk/model" + adksession "google.golang.org/adk/session" + "google.golang.org/genai" +) + +// gatherTokenUsageCount returns the total number of observations recorded on the +// gen_ai_client_token_usage histogram (summed across all label series). +func gatherTokenUsageCount(t *testing.T) uint64 { + t.Helper() + mfs, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("gather: %v", err) + } + var total uint64 + for _, mf := range mfs { + if mf.GetName() != "gen_ai_client_token_usage" { + continue + } + for _, m := range mf.GetMetric() { + total += m.GetHistogram().GetSampleCount() + } + } + return total +} + +// TestExecutor_StreamingUsageNotDoubleCounted verifies that streamed LLM calls +// record one input + one output observation per call, not one per stream chunk. +// Partial events are skipped even when they carry usage metadata. +func TestExecutor_StreamingUsageNotDoubleCounted(t *testing.T) { + e := &KAgentExecutor{modelName: "gemini-2.5-flash", providerName: "gcp.vertex_ai"} + usage := &genai.GenerateContentResponseUsageMetadata{PromptTokenCount: 10, CandidatesTokenCount: 5} + + before := gatherTokenUsageCount(t) + + const numCalls, chunksPerCall = 3, 4 + for range numCalls { + // Streaming partial chunks that also carry usage — must be skipped. + for range chunksPerCall { + e.recordTokenUsage(&adksession.Event{LLMResponse: adkmodel.LLMResponse{Partial: true, UsageMetadata: usage}}) + } + // Final aggregated (non-partial) event — the one that counts. + e.recordTokenUsage(&adksession.Event{LLMResponse: adkmodel.LLMResponse{UsageMetadata: usage}}) + } + + // One input + one output observation per LLM call, regardless of chunk count. + if got, want := gatherTokenUsageCount(t)-before, uint64(numCalls*2); got != want { + t.Fatalf("token usage observations = %d, want %d (must count %d LLM calls, not %d stream chunks)", + got, want, numCalls, numCalls*(chunksPerCall+1)) + } +} diff --git a/go/adk/pkg/telemetry/metrics.go b/go/adk/pkg/telemetry/metrics.go index 69e1925a96..b1cecc5848 100644 --- a/go/adk/pkg/telemetry/metrics.go +++ b/go/adk/pkg/telemetry/metrics.go @@ -13,17 +13,19 @@ import ( // Metric and attribute names follow the OpenTelemetry GenAI semantic // conventions (semconv 1.40.0), mapped to Prometheus naming (dots -> underscores): // - metric gen_ai.client.token.usage -> gen_ai_client_token_usage -// - attrs gen_ai.token.type, gen_ai.request.model, gen_ai.provider.name, -// gen_ai.operation.name +// - attrs gen_ai.token.type, gen_ai.operation.name, gen_ai.provider.name, +// gen_ai.request.model, gen_ai.response.model, error.type // // https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/ const ( metricGenAIClientTokenUsage = "gen_ai_client_token_usage" labelGenAITokenType = "gen_ai_token_type" - labelGenAIRequestModel = "gen_ai_request_model" - labelGenAIProviderName = "gen_ai_provider_name" labelGenAIOperationName = "gen_ai_operation_name" + labelGenAIProviderName = "gen_ai_provider_name" + labelGenAIRequestModel = "gen_ai_request_model" + labelGenAIResponseModel = "gen_ai_response_model" + labelErrorType = "error_type" tokenTypeInput = "input" tokenTypeOutput = "output" @@ -33,19 +35,71 @@ const ( operationChat = "chat" ) +// tokenUsageBuckets is the explicit bucket layout recommended by the GenAI +// metrics semantic conventions for gen_ai.client.token.usage. +// https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage +var tokenUsageBuckets = []float64{1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864} + // tokenUsage is the gen_ai.client.token.usage histogram, registered on the // default Prometheus registry so it is served by MetricsHandler alongside the // standard Go/process collectors. var tokenUsage = promauto.NewHistogramVec( prometheus.HistogramOpts{ - Name: metricGenAIClientTokenUsage, - Help: "Measures the number of input and output tokens used by GenAI requests.", - // Token-count buckets spanning short prompts to large-context requests. - Buckets: []float64{1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576}, + Name: metricGenAIClientTokenUsage, + Help: "Measures the number of input and output tokens used by GenAI requests.", + Buckets: tokenUsageBuckets, + }, + []string{ + labelGenAITokenType, + labelGenAIOperationName, + labelGenAIProviderName, + labelGenAIRequestModel, + labelGenAIResponseModel, + labelErrorType, }, - []string{labelGenAITokenType, labelGenAIRequestModel, labelGenAIProviderName, labelGenAIOperationName}, ) +// SemconvProviderName maps a kagent model type (adk.Model.GetType()) to the +// OpenTelemetry GenAI well-known gen_ai.provider.name value. Types without a +// well-known mapping (e.g. ollama, sap_ai_core, custom) pass through unchanged. +// https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-provider-name +func SemconvProviderName(modelType string) string { + switch modelType { + case "openai": + return "openai" + case "azure_openai": + return "azure.ai.openai" + case "anthropic": + return "anthropic" + case "gemini": + return "gcp.gemini" + case "gemini_vertex_ai", "gemini_anthropic": + return "gcp.vertex_ai" + case "bedrock": + return "aws.bedrock" + default: + return modelType + } +} + +// TokenUsage carries the per-request labels and counts for one recording on the +// gen_ai.client.token.usage histogram. +type TokenUsage struct { + // RequestModel is gen_ai.request.model (the configured model). + RequestModel string + // ResponseModel is gen_ai.response.model (the model the provider actually + // served). Falls back to RequestModel when empty. + ResponseModel string + // Provider is gen_ai.provider.name (a semconv well-known value). + Provider string + // ErrorType is error.type; empty for successful requests. + ErrorType string + // InputTokens / OutputTokens are the token counts (output = candidate + + // reasoning tokens). Non-positive counts are skipped. + InputTokens int64 + OutputTokens int64 +} + // MetricsHandler returns an http.Handler that serves the agent's Prometheus // metrics, intended to be mounted at /metrics for scraping. func MetricsHandler() http.Handler { @@ -53,14 +107,18 @@ func MetricsHandler() http.Handler { } // RecordTokenUsage records input/output token counts on the -// gen_ai.client.token.usage histogram, labelled with the request model, provider -// and (implicit) chat operation. Zero counts are skipped; empty model/provider -// are recorded as empty label values. -func RecordTokenUsage(model, provider string, inputTokens, outputTokens int64) { - if inputTokens > 0 { - tokenUsage.WithLabelValues(tokenTypeInput, model, provider, operationChat).Observe(float64(inputTokens)) +// gen_ai.client.token.usage histogram. Zero/negative counts are skipped. +func RecordTokenUsage(u TokenUsage) { + responseModel := u.ResponseModel + if responseModel == "" { + responseModel = u.RequestModel + } + if u.InputTokens > 0 { + tokenUsage.WithLabelValues(tokenTypeInput, operationChat, u.Provider, u.RequestModel, responseModel, u.ErrorType). + Observe(float64(u.InputTokens)) } - if outputTokens > 0 { - tokenUsage.WithLabelValues(tokenTypeOutput, model, provider, operationChat).Observe(float64(outputTokens)) + if u.OutputTokens > 0 { + tokenUsage.WithLabelValues(tokenTypeOutput, operationChat, u.Provider, u.RequestModel, responseModel, u.ErrorType). + Observe(float64(u.OutputTokens)) } } diff --git a/go/adk/pkg/telemetry/metrics_test.go b/go/adk/pkg/telemetry/metrics_test.go index f763954d92..5bf7433a44 100644 --- a/go/adk/pkg/telemetry/metrics_test.go +++ b/go/adk/pkg/telemetry/metrics_test.go @@ -14,7 +14,9 @@ import ( func TestRecordTokenUsage_RecordsHistogram(t *testing.T) { tokenUsage.Reset() - RecordTokenUsage("gpt-4o", "openai", 100, 42) + RecordTokenUsage(TokenUsage{ + RequestModel: "gpt-4o", Provider: "openai", InputTokens: 100, OutputTokens: 42, + }) // One series per token type (input, output). if got := testutil.CollectAndCount(tokenUsage); got != 2 { @@ -26,38 +28,84 @@ func TestRecordTokenUsage_RecordsHistogram(t *testing.T) { func TestRecordTokenUsage_SkipsZero(t *testing.T) { tokenUsage.Reset() - RecordTokenUsage("gpt-4o", "openai", 0, 0) + RecordTokenUsage(TokenUsage{RequestModel: "gpt-4o", Provider: "openai"}) if got := testutil.CollectAndCount(tokenUsage); got != 0 { t.Fatalf("expected no series for zero token counts, got %d", got) } } -// TestMetricsHandler_ServesTokenUsage verifies the /metrics handler exposes the -// recorded gen_ai_client_token_usage series with the expected semconv labels in -// the Prometheus text format. -func TestMetricsHandler_ServesTokenUsage(t *testing.T) { +// TestRecordTokenUsage_ResponseModelFallback verifies response model defaults to +// the request model when unset, and is used when provided. +func TestRecordTokenUsage_ResponseModelFallback(t *testing.T) { tokenUsage.Reset() - RecordTokenUsage("claude-3-5-sonnet", "anthropic", 10, 5) + RecordTokenUsage(TokenUsage{RequestModel: "gemini-2.5-flash", Provider: "gcp.vertex_ai", InputTokens: 3}) + RecordTokenUsage(TokenUsage{RequestModel: "gemini-2.5-flash", ResponseModel: "gemini-2.5-flash-002", Provider: "gcp.vertex_ai", InputTokens: 3}) - req := httptest.NewRequest(http.MethodGet, "/metrics", nil) - rec := httptest.NewRecorder() - MetricsHandler().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rec.Code) + body := serveMetrics(t) + if !strings.Contains(body, `gen_ai_response_model="gemini-2.5-flash"`) { + t.Errorf("expected response model to fall back to request model") + } + if !strings.Contains(body, `gen_ai_response_model="gemini-2.5-flash-002"`) { + t.Errorf("expected explicit response model to be used") } - body := rec.Body.String() +} + +// TestMetricsHandler_ServesLabels verifies the /metrics handler exposes the +// semconv labels, including error.type and provider/response model. +func TestMetricsHandler_ServesLabels(t *testing.T) { + tokenUsage.Reset() + RecordTokenUsage(TokenUsage{ + RequestModel: "claude-3-5-sonnet", ResponseModel: "claude-3-5-sonnet-20241022", + Provider: "anthropic", ErrorType: "overloaded_error", InputTokens: 10, OutputTokens: 5, + }) + + body := serveMetrics(t) for _, want := range []string{ "gen_ai_client_token_usage_count", `gen_ai_token_type="input"`, `gen_ai_token_type="output"`, - `gen_ai_request_model="claude-3-5-sonnet"`, - `gen_ai_provider_name="anthropic"`, `gen_ai_operation_name="chat"`, + `gen_ai_provider_name="anthropic"`, + `gen_ai_request_model="claude-3-5-sonnet"`, + `gen_ai_response_model="claude-3-5-sonnet-20241022"`, + `error_type="overloaded_error"`, } { if !strings.Contains(body, want) { t.Errorf("metrics output missing %q", want) } } } + +// TestSemconvProviderName verifies kagent model types map to well-known +// gen_ai.provider.name values, with unknown types passing through. +func TestSemconvProviderName(t *testing.T) { + cases := map[string]string{ + "openai": "openai", + "azure_openai": "azure.ai.openai", + "anthropic": "anthropic", + "gemini": "gcp.gemini", + "gemini_vertex_ai": "gcp.vertex_ai", + "gemini_anthropic": "gcp.vertex_ai", + "bedrock": "aws.bedrock", + "ollama": "ollama", // pass-through (no well-known value) + "sap_ai_core": "sap_ai_core", // pass-through + "some-custom": "some-custom", + } + for in, want := range cases { + if got := SemconvProviderName(in); got != want { + t.Errorf("SemconvProviderName(%q) = %q, want %q", in, got, want) + } + } +} + +func serveMetrics(t *testing.T) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + MetricsHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + return rec.Body.String() +} diff --git a/go/core/internal/controller/translator/agent/manifest_builder.go b/go/core/internal/controller/translator/agent/manifest_builder.go index 67c637a78b..56068c387f 100644 --- a/go/core/internal/controller/translator/agent/manifest_builder.go +++ b/go/core/internal/controller/translator/agent/manifest_builder.go @@ -503,6 +503,14 @@ func buildPodTemplate( } podTemplateAnnotations[configHashAnnotation] = fmt.Sprintf("%d", configHash) + // The Go ADK runtime serves GenAI Prometheus metrics (gen_ai.client.token.usage) + // on /metrics of the agent port; advertise it for Prometheus pod discovery. + if agentRuntime(manifestCtx.agent) == v1alpha2.DeclarativeRuntime_Go { + podTemplateAnnotations["prometheus.io/scrape"] = "true" + podTemplateAnnotations["prometheus.io/port"] = fmt.Sprintf("%d", dep.Port) + podTemplateAnnotations["prometheus.io/path"] = "/metrics" + } + probeConf := getRuntimeProbeConfig(agentRuntime(manifestCtx.agent)) var cmd []string diff --git a/go/core/internal/controller/translator/agent/runtime_test.go b/go/core/internal/controller/translator/agent/runtime_test.go index f196415ed8..0fb5765def 100644 --- a/go/core/internal/controller/translator/agent/runtime_test.go +++ b/go/core/internal/controller/translator/agent/runtime_test.go @@ -2,6 +2,7 @@ package agent_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -487,3 +488,58 @@ func TestRuntime_CustomRepositoryPath_WithSkillsUsesFullTag(t *testing.T) { assert.Contains(t, container.Image, "my-registry.com/custom/golang-adk", "Image should use custom repository with golang-adk") assert.Contains(t, container.Image, "@sha256:test-go-full", "Go runtime with skills should use digest-pinned golang-adk-full image") } + +// deployAgent translates an agent and returns its Deployment from the manifest. +func deployAgent(t *testing.T, agent *v1alpha2.Agent, modelConfig *v1alpha2.ModelConfig) *appsv1.Deployment { + t.Helper() + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(agent, modelConfig).Build() + trans := translator.NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: agent.Namespace, Name: modelConfig.Name}, nil, "", nil) + result, err := translator.TranslateAgent(context.Background(), trans, agent) + require.NoError(t, err) + for _, obj := range result.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + return dep + } + } + t.Fatal("Deployment not found in manifest") + return nil +} + +func goOrPythonAgent(name string, runtime v1alpha2.DeclarativeRuntime) (*v1alpha2.Agent, *v1alpha2.ModelConfig) { + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: runtime, SystemMessage: "test", ModelConfig: "test-model", + }, + }, + } + mc := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-model", Namespace: "test"}, + Spec: v1alpha2.ModelConfigSpec{Provider: "OpenAI", Model: "gpt-4o"}, + } + return agent, mc +} + +// TestRuntime_GoRuntimeExposesMetricsScrapeAnnotations verifies Go-runtime agent +// pods advertise the Prometheus /metrics endpoint (gen_ai.client.token.usage), +// and that Python-runtime pods (not instrumented) do not. +func TestRuntime_GoRuntimeExposesMetricsScrapeAnnotations(t *testing.T) { + withGoRuntimeDigests(t) + withPythonRuntimeDigest(t) + + goAgent, mc := goOrPythonAgent("test-go-metrics", v1alpha2.DeclarativeRuntime_Go) + goDep := deployAgent(t, goAgent, mc) + ann := goDep.Spec.Template.Annotations + assert.Equal(t, "true", ann["prometheus.io/scrape"], "Go runtime pods should be scrape-annotated") + assert.Equal(t, "/metrics", ann["prometheus.io/path"]) + require.Len(t, goDep.Spec.Template.Spec.Containers, 1) + assert.Equal(t, fmt.Sprintf("%d", goDep.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort), ann["prometheus.io/port"]) + + pyAgent, _ := goOrPythonAgent("test-py-metrics", v1alpha2.DeclarativeRuntime_Python) + pyDep := deployAgent(t, pyAgent, mc) + assert.Empty(t, pyDep.Spec.Template.Annotations["prometheus.io/scrape"], "Python runtime pods should not be scrape-annotated") +}