Emit gen_ai.client.token.usage and operation.duration metrics from the OTel middleware#666
Conversation
…el middleware
The OTel middleware recorded token counts only as span attributes, which are
not aggregatable across runs, so a spend/latency dashboard saw nothing from the
Go port. Add a Meter alongside the tracer and record the two histograms the
GenAI semantic conventions define and the Python/.NET SDKs emit:
gen_ai.client.token.usage ({token}) split by gen_ai.token.type input/output, and
gen_ai.client.operation.duration (s). Both are recorded on the normal and
early-return exit paths, tagged with operation/provider/agent name plus
error.type when the run faults. Histogram-construction failures leave a working
tracer-only middleware.
026998e to
5773147
Compare
Cross-SDK Parity ReviewPR in scope — adds Upstream reference checked: FindingsThe Go change aligns well with Python in metric names, 1. Missing explicit histogram bucket boundaries (meaningful divergence)The Python SDK defines advisory bucket boundaries for both instruments: # python/packages/core/agent_framework/observability.py
TOKEN_USAGE_BUCKET_BOUNDARIES = (1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, ...)
OPERATION_DURATION_BUCKET_BOUNDARIES = (0.01, 0.02, 0.04, 0.08, 0.16, 0.32, ...)
def _get_token_usage_histogram():
return get_meter().create_histogram(
name=OtelAttr.LLM_TOKEN_USAGE,
explicit_bucket_boundaries_advisory=TOKEN_USAGE_BUCKET_BOUNDARIES,
)The Go PR omits 2. Token usage unit string divergence (minor / spec alignment note)Python uses SummaryThe core behavior (metric names, token-type tagging, error attribution, nil-histogram safety) is well-aligned with the Python upstream. The bucket-boundary gap is the most impactful change needed to achieve full cross-language dashboard compatibility. Parity reviewer: GitHub Copilot CLI Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
What
The OTel middleware previously recorded token counts purely as span attributes (
gen_ai.usage.*) viasetUsage, and never measured run duration at all. It imported onlyotel/{attribute,codes,trace}and held a lonetracer— no Meter, no histograms.Span attributes describe a single run and are not aggregatable across runs, so a spend/latency dashboard summing across many invocations saw nothing from the Go port. This change adds a Meter alongside the tracer and emits the two histograms the OTel GenAI semantic conventions define:
gen_ai.client.token.usage(unit{token}) — two data points per run, one taggedgen_ai.token.type=input(InputTokenCount) and oneoutput(OutputTokenCount).gen_ai.client.operation.duration(units) — one sample per run, measured from beforespan.Start.Every data point carries the run's identifying attributes (
gen_ai.operation.name,gen_ai.provider.name,gen_ai.agent.name), pluserror.typewhen the run faulted. Recording is factored into arecordMetricshelper (mirroringsetUsage) shared by both the normal post-loop path and the early-return path. Histogram construction errors are tolerated:NewMiddlewarestill returns a working tracer-only middleware andrecordMetricsno-ops on a nil histogram.Why (cross-SDK parity)
This aligns the Go port with the OTel GenAI semantic conventions and the Python/.NET references, which both emit these two histograms. Cross-language dashboards can now aggregate Go runs alongside the rest. Span-attribute accounting stays exactly as-is; metrics complement it (single-run detail vs. cross-run aggregation) rather than replacing it. Not a duplicate of the span PRs (#655/#646) or usage-accounting PRs (#594/#609/#551-554).
Tests
provider/otelprovider/otel_test.gogains two black-box tests driving an in-memorysdkmetric.ManualReader:TestOtel_Run_RecordsUsageAndDurationMetrics— runs the middleware over a fakeRunFuncyieldingUsageContent(100 input / 50 output), collectsResourceMetrics, and asserts the twogen_ai.client.token.usagedata points (input=100, output=50 bygen_ai.token.type) with the shared attributes, plus onegen_ai.client.operation.durationsample.TestOtel_Run_MetricsIncludeErrorType— assertserror.typeis present on the duration metric when the run faults.Both fail before the change and pass after.
go build ./...,go vet ./provider/otelprovider/..., andgo test ./provider/otelprovider/...are green.Open design questions
MiddlewareConfigis unchanged — the Meter reusesSourceNamefor its instrumentation scope, matching the tracer. Open to a separate meter name if preferred.AdditionalCountsand cache/reasoning sub-counters are not yet emitted as metric data points (the conventions only define input/output token-type dimensions); could be added if there's appetite.