Skip to content

Commit 353d7be

Browse files
committed
chore(adk): address code review feedback on token usage metric
Signed-off-by: MartinForReal <fanshangxiang@gmail.com>
1 parent 5475f7d commit 353d7be

13 files changed

Lines changed: 435 additions & 147 deletions

File tree

‎docs/genai-token-metrics.md‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,22 @@ A Prometheus histogram, served at **`/metrics`** on the agent's HTTP port:
1313
| --- | --- | --- |
1414
| `gen_ai_client_token_usage` | `gen_ai.client.token.usage` | histogram, semconv-recommended buckets |
1515

16-
Labels (semconv attributes, dots → underscores), aligned with what the upstream Google ADK Python
17-
runtime emits for the same instrument so a single dashboard works across both runtimes:
16+
Labels use semconv attribute names with dots mapped to underscores:
1817

1918
| Label | Values |
2019
| --- | --- |
2120
| `gen_ai_token_type` | `input`, `output` (output = candidate + reasoning tokens) |
22-
| `gen_ai_operation_name` | `chat` |
21+
| `gen_ai_operation_name` | `generate_content` for Gemini; `chat` for chat-completion providers |
2322
| `gen_ai_provider_name` | well-known value, e.g. `openai`, `anthropic`, `gcp.vertex_ai`, `aws.bedrock`, `azure.ai.openai` |
2423
| `gen_ai_request_model` | configured model, e.g. `gpt-4o` |
2524
| `gen_ai_response_model` | model the provider served (falls back to request model) |
26-
| `gen_ai_agent_name` | agent that produced the tokens (the kagent app name) |
25+
| `gen_ai_agent_name` | ADK agent that made the model call (not the application name) |
2726
| `error_type` | set on failed requests; empty otherwise |
2827

29-
One observation is recorded per LLM call (streaming partial chunks are not double-counted).
28+
For each positive input/output count, one observation is recorded per LLM call.
29+
Streaming partial chunks are not double-counted. Recording runs in the model callback,
30+
so usage-only responses are counted even when they do not produce an A2A artifact.
31+
Missing optional labels are exported as empty strings.
3032

3133
## Configuration
3234

@@ -89,14 +91,14 @@ chunks, and the semconv labels including `gen_ai.agent.name`, response model, an
8991
```text
9092
# HELP gen_ai_client_token_usage Measures the number of input and output tokens used by GenAI requests.
9193
# TYPE gen_ai_client_token_usage histogram
92-
gen_ai_client_token_usage_sum{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="chat",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_token_type="input"} 137
93-
gen_ai_client_token_usage_count{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="chat",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_token_type="input"} 2
94-
gen_ai_client_token_usage_sum{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="chat",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_token_type="output"} 91
95-
gen_ai_client_token_usage_count{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="chat",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_token_type="output"} 2
94+
gen_ai_client_token_usage_sum{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="generate_content",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_token_type="input"} 137
95+
gen_ai_client_token_usage_count{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="generate_content",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_token_type="input"} 2
96+
gen_ai_client_token_usage_sum{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="generate_content",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_token_type="output"} 91
97+
gen_ai_client_token_usage_count{error_type="",gen_ai_agent_name="my_agent",gen_ai_operation_name="generate_content",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_token_type="output"} 2
9698
```
9799

98100
## Follow-ups
99101

100102
- Optional OTLP **push** (in addition to the scrape endpoint) via the OpenTelemetry
101103
[Prometheus→OTLP bridge](https://pkg.go.dev/go.opentelemetry.io/contrib/bridges/prometheus), for
102-
environments that push to an OTLP collector rather than scrape.
104+
environments that push to an OTLP collector rather than scrape.

‎go/adk/cmd/main.go‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
runnerpkg "github.com/kagent-dev/kagent/go/adk/pkg/runner"
2121
"github.com/kagent-dev/kagent/go/adk/pkg/session"
2222
"github.com/kagent-dev/kagent/go/adk/pkg/telemetry"
23-
"github.com/kagent-dev/kagent/go/api/adk"
2423
"github.com/kagent-dev/kagent/go/core/pkg/env"
2524
"github.com/kagent-dev/kagent/go/pkg/logging"
2625
"github.com/kagent-dev/kagent/go/pkg/tracing"
@@ -196,16 +195,13 @@ func main() {
196195
}
197196

198197
stream := agentConfig.GetStream()
199-
modelName, providerName := resolveModelLabels(agentConfig)
200198
executor, err := a2a.NewKAgentExecutor(a2a.KAgentExecutorConfig{
201199
RunnerConfig: runnerConfig,
202200
SessionService: sessionService,
203201
Stream: stream,
204202
AppName: appName,
205203
Logger: logger,
206204
Output: agentConfig.Output,
207-
ModelName: modelName,
208-
ProviderName: providerName,
209205
})
210206
if err != nil {
211207
logger.Error("failed to create A2A executor", "error", err)
@@ -249,16 +245,6 @@ func main() {
249245
}
250246
}
251247

252-
// resolveModelLabels resolves the gen_ai.request.model / gen_ai.provider.name
253-
// labels for token-usage metrics from the agent config. Returns empty strings
254-
// when no model is configured; the metric simply omits those attributes.
255-
func resolveModelLabels(agentConfig *adk.AgentConfig) (model, provider string) {
256-
if agentConfig == nil || agentConfig.Model == nil {
257-
return "", ""
258-
}
259-
return config.ModelName(agentConfig.Model), telemetry.SemconvProviderName(agentConfig.Model.GetType())
260-
}
261-
262248
func deriveAppName(kagentName, kagentNamespace string, agentCard *a2atype.AgentCard, logger *slog.Logger) string {
263249
if kagentNamespace != "" && kagentName != "" {
264250
namespace := strings.ReplaceAll(kagentNamespace, "-", "_")

‎go/adk/pkg/a2a/executor.go‎

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,6 @@ type KAgentExecutorConfig struct {
4141
AppName string
4242
Logger *slog.Logger
4343
Output *apiadk.OutputConfig
44-
// ModelName and ProviderName label GenAI token-usage metrics
45-
// (gen_ai.request.model / gen_ai.provider.name). Both may be empty, in
46-
// which case the corresponding metric attributes are omitted.
47-
ModelName string
48-
ProviderName string
4944
}
5045

5146
// KAgentExecutor keeps kagent's request/session glue around the upstream ADK
@@ -106,7 +101,6 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) (*KAgentExecutor, error) {
106101
}
107102
processed.Artifact.SetMeta(apia2a.TimelinePositionMetadataKey, position.UTC().Format(time.RFC3339Nano))
108103
}
109-
recordTokenUsage(cfg.ModelName, cfg.ProviderName, cfg.AppName, event)
110104
return transformStructuredOutput(output, rootName, event, processed)
111105
},
112106
OutputMode: adka2a.OutputArtifactPerEvent,
@@ -208,30 +202,6 @@ func resolveStructuredOutput(config *apiadk.OutputConfig) (*structuredOutput, er
208202
return &structuredOutput{schema: resolved, sha256: config.SHA256}, nil
209203
}
210204

211-
// recordTokenUsage records GenAI token usage for a single ADK event on the
212-
// gen_ai.client.token.usage histogram. Partial (streaming) events are skipped:
213-
// a streamed LLM call emits many Partial chunks but usage is reported once on
214-
// the aggregated non-partial event, so this counts one observation per LLM
215-
// call, not per stream chunk. Output combines candidate + reasoning tokens.
216-
func recordTokenUsage(modelName, providerName, agentName string, adkEvent *adksession.Event) {
217-
if adkEvent == nil {
218-
return
219-
}
220-
um := adkEvent.UsageMetadata
221-
if um == nil || adkEvent.Partial {
222-
return
223-
}
224-
telemetry.RecordTokenUsage(telemetry.TokenUsage{
225-
RequestModel: modelName,
226-
ResponseModel: adkEvent.ModelVersion,
227-
Provider: providerName,
228-
AgentName: agentName,
229-
ErrorType: adkEvent.ErrorCode,
230-
InputTokens: int64(um.PromptTokenCount),
231-
OutputTokens: int64(um.CandidatesTokenCount) + int64(um.ThoughtsTokenCount),
232-
})
233-
}
234-
235205
// UserIDCallInterceptor returns an a2asrv.CallInterceptor that extracts the
236206
// x-user-id HTTP header from the incoming request metadata and sets it as the
237207
// authenticated user on the CallContext.

‎go/adk/pkg/a2a/executor_metrics_test.go‎

Lines changed: 0 additions & 57 deletions
This file was deleted.

‎go/adk/pkg/a2a/server/server.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"google.golang.org/grpc/health"
2525
grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1"
2626

27+
"github.com/kagent-dev/kagent/go/adk/pkg/telemetry"
2728
"github.com/kagent-dev/kagent/go/pkg/tracing"
2829
)
2930

‎go/adk/pkg/a2a/server/server_test.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1"
1919
"github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv"
2020
"github.com/a2aproject/a2a-go/v2/a2asrv"
21+
"github.com/stretchr/testify/require"
2122
"go.opentelemetry.io/otel"
2223
sdktrace "go.opentelemetry.io/otel/sdk/trace"
2324
"go.opentelemetry.io/otel/sdk/trace/tracetest"
@@ -28,6 +29,44 @@ import (
2829
"github.com/kagent-dev/kagent/go/adk/pkg/telemetry"
2930
)
3031

32+
func TestMetricsEndpoint(t *testing.T) {
33+
for _, test := range []struct {
34+
name string
35+
input string
36+
want bool
37+
}{
38+
{name: "disabled"},
39+
{name: "enabled", input: "true", want: true},
40+
} {
41+
t.Run(test.name, func(t *testing.T) {
42+
t.Setenv("OTEL_METRICS_ENABLED", test.input)
43+
t.Setenv("KAGENT_PRE_RESPONSE_TRACE_FLUSH", "true")
44+
exporter := tracetest.NewInMemoryExporter()
45+
provider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
46+
previous := otel.GetTracerProvider()
47+
otel.SetTracerProvider(provider)
48+
t.Cleanup(func() {
49+
otel.SetTracerProvider(previous)
50+
require.NoError(t, provider.Shutdown(context.Background()))
51+
})
52+
testServer, _ := startTestServer(t)
53+
telemetry.RecordTokenUsage(telemetry.TokenUsage{RequestModel: "test-model", Provider: "openai", InputTokens: 10})
54+
response, err := testServer.Client().Get(testServer.URL + "/metrics")
55+
require.NoError(t, err)
56+
t.Cleanup(func() { require.NoError(t, response.Body.Close()) })
57+
body, err := io.ReadAll(response.Body)
58+
require.NoError(t, err)
59+
if test.want {
60+
require.Equal(t, http.StatusOK, response.StatusCode)
61+
require.Contains(t, string(body), "gen_ai_client_token_usage_count")
62+
} else {
63+
require.NotContains(t, string(body), "gen_ai_client_token_usage")
64+
}
65+
require.Empty(t, exporter.GetSpans())
66+
})
67+
}
68+
}
69+
3170
// substrateExecutor mimics KAgentExecutor's telemetry: it starts the
3271
// invocation span from the request-derived context. It does not flush —
3372
// exporting everything (including the otelhttp server span, still open

‎go/adk/pkg/config/config_usage.go‎

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func GetAgentConfigSummary(config *adk.AgentConfig) string {
7676

7777
summary := "AgentConfig:\n"
7878
if config.Model != nil {
79-
summary += fmt.Sprintf(" Model: %s (%s)\n", config.Model.GetType(), getModelName(config.Model))
79+
summary += fmt.Sprintf(" Model: %s (%s)\n", config.Model.GetType(), GetModelName(config.Model))
8080
} else {
8181
summary += " Model: (nil)\n"
8282
}
@@ -91,30 +91,32 @@ func GetAgentConfigSummary(config *adk.AgentConfig) string {
9191
return summary
9292
}
9393

94-
func getModelName(m adk.Model) string {
95-
switch m := m.(type) {
94+
// GetModelName returns the configured model identifier, or "unknown" for an unrecognized model type.
95+
func GetModelName(model adk.Model) string {
96+
switch model := model.(type) {
9697
case *adk.OpenAI:
97-
return m.Model
98+
return model.Model
9899
case *adk.AzureOpenAI:
99-
return m.Model
100+
return model.Model
100101
case *adk.Anthropic:
101-
return m.Model
102+
return model.Model
102103
case *adk.GeminiVertexAI:
103-
return m.Model
104+
return model.Model
104105
case *adk.GeminiAnthropic:
105-
return m.Model
106+
return model.Model
106107
case *adk.Ollama:
107-
return m.Model
108+
return model.Model
108109
case *adk.Gemini:
109-
return m.Model
110+
return model.Model
111+
case *adk.Bedrock:
112+
return model.Model
113+
case *adk.SAPAICore:
114+
return model.Model
115+
case *adk.Foundry:
116+
return model.Model
117+
case *adk.GenericModel:
118+
return model.Model
110119
default:
111120
return "unknown"
112121
}
113122
}
114-
115-
// ModelName returns the configured model identifier (e.g. "gpt-4o",
116-
// "claude-3-5-sonnet"), or "unknown" for an unrecognized model type. It is the
117-
// exported form of getModelName, used to label GenAI telemetry.
118-
func ModelName(m adk.Model) string {
119-
return getModelName(m)
120-
}

‎go/adk/pkg/config/config_usage_test.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,36 @@ import (
55
"testing"
66

77
"github.com/kagent-dev/kagent/go/api/adk"
8+
"github.com/stretchr/testify/require"
89
)
910

11+
func TestGetModelName(t *testing.T) {
12+
base := adk.BaseModel{Model: "configured-model"}
13+
for _, test := range []struct {
14+
name string
15+
input adk.Model
16+
want string
17+
}{
18+
{name: "openai", input: &adk.OpenAI{BaseModel: base}, want: base.Model},
19+
{name: "azure", input: &adk.AzureOpenAI{BaseModel: base}, want: base.Model},
20+
{name: "anthropic", input: &adk.Anthropic{BaseModel: base}, want: base.Model},
21+
{name: "gemini", input: &adk.Gemini{BaseModel: base}, want: base.Model},
22+
{name: "vertex", input: &adk.GeminiVertexAI{BaseModel: base}, want: base.Model},
23+
{name: "vertex anthropic", input: &adk.GeminiAnthropic{BaseModel: base}, want: base.Model},
24+
{name: "ollama", input: &adk.Ollama{BaseModel: base}, want: base.Model},
25+
{name: "bedrock", input: &adk.Bedrock{BaseModel: base}, want: base.Model},
26+
{name: "sap", input: &adk.SAPAICore{BaseModel: base}, want: base.Model},
27+
{name: "foundry", input: &adk.Foundry{BaseModel: base}, want: base.Model},
28+
{name: "generic", input: &adk.GenericModel{BaseModel: base}, want: base.Model},
29+
{name: "nil", want: "unknown"},
30+
{name: "empty", input: &adk.OpenAI{}},
31+
} {
32+
t.Run(test.name, func(t *testing.T) {
33+
require.Equal(t, test.want, GetModelName(test.input))
34+
})
35+
}
36+
}
37+
1038
func TestValidateAgentConfigUsage_NilConfig(t *testing.T) {
1139
err := ValidateAgentConfigUsage(nil)
1240
if err == nil {

‎go/adk/pkg/runner/adapter.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ func CreateRunnerConfig(
8282
}
8383

8484
var adkPlugins []*adkplugin.Plugin
85+
metricsPlugin, err := newTokenUsagePlugin(agentConfig.Model)
86+
if err != nil {
87+
return runner.Config{}, fmt.Errorf("failed to create token usage plugin: %w", err)
88+
}
89+
adkPlugins = append(adkPlugins, metricsPlugin)
8590
if stsPlugin != nil {
8691
p, err := stsPlugin.ADKPlugin()
8792
if err != nil {

0 commit comments

Comments
 (0)