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 cc5a61bbf5..2799135ab2 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), telemetry.SemconvProviderName(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..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 ( @@ -37,6 +38,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 +54,8 @@ type KAgentExecutor struct { appName string skillsDirectory string logger logr.Logger + modelName string + providerName string } var _ a2asrv.AgentExecutor = (*KAgentExecutor)(nil) @@ -69,9 +77,31 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) *KAgentExecutor { appName: cfg.AppName, skillsDirectory: skillsDir, logger: cfg.Logger.WithName("kagent-executor"), + modelName: cfg.ModelName, + providerName: cfg.ProviderName, } } +// 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. @@ -288,6 +318,9 @@ 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 for this event. + e.recordTokenUsage(adkEvent) + // 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/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/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..b1cecc5848 --- /dev/null +++ b/go/adk/pkg/telemetry/metrics.go @@ -0,0 +1,124 @@ +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.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" + 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" + + // operationChat is the gen_ai.operation.name for the chat-completion calls + // that produce the token usage recorded here. + 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.", + Buckets: tokenUsageBuckets, + }, + []string{ + labelGenAITokenType, + labelGenAIOperationName, + labelGenAIProviderName, + labelGenAIRequestModel, + labelGenAIResponseModel, + labelErrorType, + }, +) + +// 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 { + return promhttp.Handler() +} + +// RecordTokenUsage records input/output token counts on the +// 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 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 new file mode 100644 index 0000000000..5bf7433a44 --- /dev/null +++ b/go/adk/pkg/telemetry/metrics_test.go @@ -0,0 +1,111 @@ +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(TokenUsage{ + RequestModel: "gpt-4o", Provider: "openai", InputTokens: 100, OutputTokens: 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(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) + } +} + +// 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(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}) + + 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") + } +} + +// 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_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") +} 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