snapshot of https://github.com/grpc/grpc-go.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
codecov / upload (push) Failing after 13m59s
CodeQL / Analyze (push) Failing after 1s
Testing / vet-proto (push) Failing after 1s
Testing / tests (arm, latest) (push) Waiting to run
Testing / extras (latest) (push) Failing after 1s
Testing / static checks (latest-1) (push) Failing after 1s
Testing / tests (-race, latest) (push) Failing after 1s
Testing / tests (i386, latest) (push) Failing after 1s
Testing / tests (latest) (push) Failing after 1s
Testing / tests (latest-1) (push) Failing after 1s
Lock Threads / lock (push) Failing after 1s
govulncheck / govulncheck (.) (push) Failing after 1s
govulncheck / govulncheck (./cmd/protoc-gen-go-grpc) (push) Failing after 1s
govulncheck / govulncheck (./gcp/observability) (push) Failing after 1s
govulncheck / govulncheck (./security/advancedtls) (push) Failing after 1s
govulncheck / govulncheck (./stats/opencensus) (push) Failing after 1s
Stale bot / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 20:40:15 +05:00
commit e241b33f26
1365 changed files with 339570 additions and 0 deletions

74
internal/stats/labels.go Normal file
View File

@@ -0,0 +1,74 @@
/*
*
* Copyright 2024 gRPC authors.
*
* 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 stats provides internal stats related functionality.
package stats
import (
"context"
"maps"
)
// LabelCallback is a function that is executed when telemetry
// label keys are updated.
type LabelCallback func(map[string]string)
type telemetryLabelCallbackKey struct{}
// UpdateLabels executes registered telemetry callbacks with the update labels. Labels
// are copied before being processed by any callbacks to ensure mutations are not
// shared among derived contexts.
//
// It is the responsibility of the registrant to handle conflicts or label resets.
func UpdateLabels(ctx context.Context, update map[string]string) {
executeTelemetryLabelCallbacks(ctx, update)
}
// RegisterTelemetryLabelCallback registers a callback function that is executed whenever
// telemetry labels are updated.
func RegisterTelemetryLabelCallback(ctx context.Context, callback LabelCallback) context.Context {
if callback == nil {
return ctx
}
callbacks, ok := ctx.Value(telemetryLabelCallbackKey{}).([]LabelCallback)
if !ok {
return context.WithValue(ctx, telemetryLabelCallbackKey{}, []LabelCallback{callback})
}
return context.WithValue(ctx, telemetryLabelCallbackKey{}, append(append([]LabelCallback(nil), callbacks...), callback))
}
// executeTelemetryLabelCallback runs the registered callbacks in the order they were
// registered on the context with the provided labels. If no callbacks are registered
// it does nothing.
//
// To ensure callbacks do not mutate the state of the provided label map it is copied
// before execution.
func executeTelemetryLabelCallbacks(ctx context.Context, labels map[string]string) {
callbacks, ok := ctx.Value(telemetryLabelCallbackKey{}).([]LabelCallback)
if !ok {
return
}
labelsCopy := map[string]string{}
maps.Copy(labelsCopy, labels)
for _, callback := range callbacks {
callback(labelsCopy)
}
}

View File

@@ -0,0 +1,216 @@
/*
*
* Copyright 2026 gRPC authors.
*
* 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 stats_test
import (
"context"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"google.golang.org/grpc/internal/stats"
)
// TestTelemetryLabelCallbacks tests registering a callback function with the context and
// the effects of executing the callback on a local label state tracker. Each test
// case constructs a new context with the provided callback registered.
func (s) TestTelemetryLabels(t *testing.T) {
commonLabelValues := map[string]string{"grpc.lb.backend_service": "grpc.lb.backend_service_val", "grpc.lb.locality": "grpc.lb.locality_val"}
tests := map[string]struct {
callbacks func(tracker map[string]string) []func(map[string]string)
additionalLabels map[string]string
wantLabels map[string]string
}{
"NilCallback": {
callbacks: func(map[string]string) []func(map[string]string) {
return []func(map[string]string){nil}
},
wantLabels: map[string]string{},
},
"NoOPCallback": {
callbacks: func(map[string]string) []func(map[string]string) {
return []func(map[string]string){
func(map[string]string) {},
}
},
wantLabels: map[string]string{},
},
"TrackingCallback": {
callbacks: func(tracker map[string]string) []func(map[string]string) {
return []func(map[string]string){
func(u map[string]string) {
for key, value := range u {
tracker[key] = value
}
},
}
},
wantLabels: map[string]string{
"grpc.lb.backend_service": "grpc.lb.backend_service_val",
"grpc.lb.locality": "grpc.lb.locality_val",
},
},
"MultipleInvocations": {
callbacks: func(tracker map[string]string) []func(map[string]string) {
return []func(map[string]string){
func(u map[string]string) {
for key, value := range u {
tracker[key] = value
}
},
}
},
additionalLabels: map[string]string{"grpc.lb.other_label": "grpc.lb.other_val"},
wantLabels: map[string]string{
"grpc.lb.backend_service": "grpc.lb.backend_service_val",
"grpc.lb.locality": "grpc.lb.locality_val",
"grpc.lb.other_label": "grpc.lb.other_val",
},
},
"MultipleInvocationsWithSameLabel": {
callbacks: func(tracker map[string]string) []func(map[string]string) {
return []func(map[string]string){
func(u map[string]string) {
for key, value := range u {
tracker[key] = value
}
},
}
},
additionalLabels: map[string]string{"grpc.lb.backend_service": "grpc.lb.backend_service_other_val"},
wantLabels: map[string]string{
"grpc.lb.backend_service": "grpc.lb.backend_service_other_val",
"grpc.lb.locality": "grpc.lb.locality_val",
},
},
"MultipleCallbacks": {
callbacks: func(tracker map[string]string) []func(map[string]string) {
return []func(map[string]string){
func(u map[string]string) {
for key, value := range u {
k, v := key+"_first", value+"_first"
tracker[k] = v
}
},
func(u map[string]string) {
for key, value := range u {
k, v := key+"_second", value+"_second"
tracker[k] = v
}
},
}
},
wantLabels: map[string]string{
"grpc.lb.backend_service_first": "grpc.lb.backend_service_val_first",
"grpc.lb.locality_first": "grpc.lb.locality_val_first",
"grpc.lb.backend_service_second": "grpc.lb.backend_service_val_second",
"grpc.lb.locality_second": "grpc.lb.locality_val_second",
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
tracker := map[string]string{}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, c := range test.callbacks(tracker) {
ctx = stats.RegisterTelemetryLabelCallback(ctx, c)
}
stats.UpdateLabels(ctx, commonLabelValues)
stats.UpdateLabels(ctx, test.additionalLabels)
if diff := cmp.Diff(tracker, test.wantLabels); diff != "" {
t.Fatalf("tracked labels did not match expected values (-got, +want): %v", diff)
}
})
}
}
// TestTelemetryLabelsMutation verifies that callbacks mutating labels do not
// affect the original map passed to UpdateLabels.
func (s) TestTelemetryLabelsMutation(t *testing.T) {
originalLabels := map[string]string{"key1": "val1", "key2": "val2"}
// Create a copy to compare against after calling UpdateLabels.
wantLabels := map[string]string{"key1": "val1", "key2": "val2"}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
ctx = stats.RegisterTelemetryLabelCallback(ctx, func(u map[string]string) {
u["key1"] = "mutated1"
u["newkey"] = "newval"
})
stats.UpdateLabels(ctx, originalLabels)
if diff := cmp.Diff(originalLabels, wantLabels); diff != "" {
t.Fatalf("original labels were mutated by callback (-got, +want): %v", diff)
}
}
// TestTelemetryLabelsDerivedContexts verifies that creating derived contexts
// with additional callbacks does not override or affect sibling contexts.
func (s) TestTelemetryLabelsDerivedContexts(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
var baseExecuted, derived1Executed, derived2Executed uint
ctxBase := stats.RegisterTelemetryLabelCallback(ctx, func(map[string]string) {
baseExecuted++
})
ctxDerived1 := stats.RegisterTelemetryLabelCallback(ctxBase, func(map[string]string) {
derived1Executed++
})
ctxDerived2 := stats.RegisterTelemetryLabelCallback(ctxBase, func(map[string]string) {
derived2Executed++
})
labels := map[string]string{"k": "v"}
// Run UpdateLabels to execute the telemetry callbacks on ctxDerived1 which should only increment
// the base and first derived context counters
stats.UpdateLabels(ctxDerived1, labels)
if baseExecuted != 1 || derived1Executed != 1 || derived2Executed != 0 {
t.Errorf("UpdateLabels(ctxDerived1) executed: base=%v, derived1=%v, derived2=%v; want 1, 1, 0", baseExecuted, derived1Executed, derived2Executed)
}
// Run UpdateLabels to execute the telemetry callbacks on ctxDerived2 which should only increment
// the base and second derived context counters
stats.UpdateLabels(ctxDerived2, labels)
if baseExecuted != 2 || derived1Executed != 1 || derived2Executed != 1 {
t.Errorf("UpdateLabels(ctxDerived2) executed: base=%v, derived1=%v, derived2=%v; want 2, 1, 1", baseExecuted, derived1Executed, derived2Executed)
}
// Run UpdateLabels to execute the telemetry callbacks on ctxBase which should only increment
// the base context counters
stats.UpdateLabels(ctxBase, labels)
if baseExecuted != 3 || derived1Executed != 1 || derived2Executed != 1 {
t.Errorf("UpdateLabels(ctxDerived2) executed: base=%v, derived1=%v, derived2=%v; want 3, 1, 1", baseExecuted, derived1Executed, derived2Executed)
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright 2024 gRPC authors.
*
* 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 stats
import (
"fmt"
estats "google.golang.org/grpc/experimental/stats"
"google.golang.org/grpc/internal"
"google.golang.org/grpc/stats"
)
// MetricsRecorderList forwards Record calls to all of its metricsRecorders.
//
// It eats any record calls where the label values provided do not match the
// number of label keys.
type MetricsRecorderList struct {
internal.EnforceMetricsRecorderEmbedding
// metricsRecorders are the metrics recorders this list will forward to.
metricsRecorders []estats.MetricsRecorder
}
// NewMetricsRecorderList creates a new metric recorder list with all the stats
// handlers provided which implement the MetricsRecorder interface.
// If no stats handlers provided implement the MetricsRecorder interface,
// the MetricsRecorder list returned is a no-op.
func NewMetricsRecorderList(shs []stats.Handler) *MetricsRecorderList {
var mrs []estats.MetricsRecorder
for _, sh := range shs {
if mr, ok := sh.(estats.MetricsRecorder); ok {
mrs = append(mrs, mr)
}
}
return &MetricsRecorderList{
metricsRecorders: mrs,
}
}
func verifyLabels(desc *estats.MetricDescriptor, labelsRecv ...string) {
if got, want := len(labelsRecv), len(desc.Labels)+len(desc.OptionalLabels); got != want {
panic(fmt.Sprintf("Received %d labels in call to record metric %q, but expected %d.", got, desc.Name, want))
}
}
// RecordInt64Count records the measurement alongside labels on the int
// count associated with the provided handle.
func (l *MetricsRecorderList) RecordInt64Count(handle *estats.Int64CountHandle, incr int64, labels ...string) {
verifyLabels(handle.Descriptor(), labels...)
for _, metricRecorder := range l.metricsRecorders {
metricRecorder.RecordInt64Count(handle, incr, labels...)
}
}
// RecordInt64UpDownCount records the measurement alongside labels on the int
// count associated with the provided handle.
func (l *MetricsRecorderList) RecordInt64UpDownCount(handle *estats.Int64UpDownCountHandle, incr int64, labels ...string) {
verifyLabels(handle.Descriptor(), labels...)
for _, metricRecorder := range l.metricsRecorders {
metricRecorder.RecordInt64UpDownCount(handle, incr, labels...)
}
}
// RecordFloat64Count records the measurement alongside labels on the float
// count associated with the provided handle.
func (l *MetricsRecorderList) RecordFloat64Count(handle *estats.Float64CountHandle, incr float64, labels ...string) {
verifyLabels(handle.Descriptor(), labels...)
for _, metricRecorder := range l.metricsRecorders {
metricRecorder.RecordFloat64Count(handle, incr, labels...)
}
}
// RecordInt64Histo records the measurement alongside labels on the int
// histo associated with the provided handle.
func (l *MetricsRecorderList) RecordInt64Histo(handle *estats.Int64HistoHandle, incr int64, labels ...string) {
verifyLabels(handle.Descriptor(), labels...)
for _, metricRecorder := range l.metricsRecorders {
metricRecorder.RecordInt64Histo(handle, incr, labels...)
}
}
// RecordFloat64Histo records the measurement alongside labels on the float
// histo associated with the provided handle.
func (l *MetricsRecorderList) RecordFloat64Histo(handle *estats.Float64HistoHandle, incr float64, labels ...string) {
verifyLabels(handle.Descriptor(), labels...)
for _, metricRecorder := range l.metricsRecorders {
metricRecorder.RecordFloat64Histo(handle, incr, labels...)
}
}
// RecordInt64Gauge records the measurement alongside labels on the int
// gauge associated with the provided handle.
func (l *MetricsRecorderList) RecordInt64Gauge(handle *estats.Int64GaugeHandle, incr int64, labels ...string) {
verifyLabels(handle.Descriptor(), labels...)
for _, metricRecorder := range l.metricsRecorders {
metricRecorder.RecordInt64Gauge(handle, incr, labels...)
}
}
// RegisterAsyncReporter forwards the registration to all underlying metrics
// recorders.
//
// It returns a cleanup function that, when called, invokes the cleanup function
// returned by each underlying recorder, ensuring the reporter is unregistered
// from all of them.
func (l *MetricsRecorderList) RegisterAsyncReporter(reporter estats.AsyncMetricReporter, metrics ...estats.AsyncMetric) func() {
descriptorsMap := make(map[*estats.MetricDescriptor]bool, len(metrics))
for _, m := range metrics {
descriptorsMap[m.Descriptor()] = true
}
unregisterFns := make([]func(), 0, len(l.metricsRecorders))
for _, mr := range l.metricsRecorders {
// Wrap the AsyncMetricsRecorder to intercept calls to RecordInt64Gauge
// and validate the labels.
wrappedCallback := func(recorder estats.AsyncMetricsRecorder) error {
wrappedRecorder := &asyncRecorderWrapper{
delegate: recorder,
descriptors: descriptorsMap,
}
return reporter.Report(wrappedRecorder)
}
unregisterFns = append(unregisterFns, mr.RegisterAsyncReporter(estats.AsyncMetricReporterFunc(wrappedCallback), metrics...))
}
// Wrap the cleanup function using the internal delegate.
// In production, this returns realCleanup as-is.
// In tests, the leak checker can swap this to track the registration lifetime.
return internal.AsyncReporterCleanupDelegate(defaultCleanUp(unregisterFns))
}
func defaultCleanUp(unregisterFns []func()) func() {
return func() {
for _, unregister := range unregisterFns {
unregister()
}
}
}
type asyncRecorderWrapper struct {
delegate estats.AsyncMetricsRecorder
descriptors map[*estats.MetricDescriptor]bool
}
// RecordIntAsync64Gauge records the measurement alongside labels on the int
// gauge associated with the provided handle.
func (w *asyncRecorderWrapper) RecordInt64AsyncGauge(handle *estats.Int64AsyncGaugeHandle, value int64, labels ...string) {
// Ensure only metrics for descriptors passed during callback registration
// are emitted.
d := handle.Descriptor()
if _, ok := w.descriptors[d]; !ok {
return
}
// Validate labels and delegate.
verifyLabels(d, labels...)
w.delegate.RecordInt64AsyncGauge(handle, value, labels...)
}

View File

@@ -0,0 +1,336 @@
/*
*
* Copyright 2024 gRPC authors.
*
* 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 stats_test implements an e2e test for the Metrics Recorder List
// component of the Client Conn.
package stats_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/balancer"
"google.golang.org/grpc/balancer/pickfirst"
"google.golang.org/grpc/credentials/insecure"
estats "google.golang.org/grpc/experimental/stats"
"google.golang.org/grpc/internal"
"google.golang.org/grpc/internal/grpctest"
istats "google.golang.org/grpc/internal/stats"
"google.golang.org/grpc/internal/testutils/stats"
testgrpc "google.golang.org/grpc/interop/grpc_testing"
testpb "google.golang.org/grpc/interop/grpc_testing"
"google.golang.org/grpc/resolver"
"google.golang.org/grpc/resolver/manual"
"google.golang.org/grpc/serviceconfig"
gstats "google.golang.org/grpc/stats"
)
var defaultTestTimeout = 5 * time.Second
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
var (
intCountHandle = estats.RegisterInt64Count(estats.MetricDescriptor{
Name: "simple counter",
Description: "sum of all emissions from tests",
Unit: "int",
Labels: []string{"int counter label"},
OptionalLabels: []string{"int counter optional label"},
Default: false,
})
floatCountHandle = estats.RegisterFloat64Count(estats.MetricDescriptor{
Name: "float counter",
Description: "sum of all emissions from tests",
Unit: "float",
Labels: []string{"float counter label"},
OptionalLabels: []string{"float counter optional label"},
Default: false,
})
intHistoHandle = estats.RegisterInt64Histo(estats.MetricDescriptor{
Name: "int histo",
Description: "sum of all emissions from tests",
Unit: "int",
Labels: []string{"int histo label"},
OptionalLabels: []string{"int histo optional label"},
Default: false,
})
floatHistoHandle = estats.RegisterFloat64Histo(estats.MetricDescriptor{
Name: "float histo",
Description: "sum of all emissions from tests",
Unit: "float",
Labels: []string{"float histo label"},
OptionalLabels: []string{"float histo optional label"},
Default: false,
})
intGaugeHandle = estats.RegisterInt64Gauge(estats.MetricDescriptor{
Name: "simple gauge",
Description: "the most recent int emitted by test",
Unit: "int",
Labels: []string{"int gauge label"},
OptionalLabels: []string{"int gauge optional label"},
Default: false,
})
)
func init() {
balancer.Register(recordingLoadBalancerBuilder{})
}
const recordingLoadBalancerName = "recording_load_balancer"
type recordingLoadBalancerBuilder struct{}
func (recordingLoadBalancerBuilder) Name() string {
return recordingLoadBalancerName
}
func (recordingLoadBalancerBuilder) Build(cc balancer.ClientConn, bOpts balancer.BuildOptions) balancer.Balancer {
intCountHandle.Record(cc.MetricsRecorder(), 1, "int counter label val", "int counter optional label val")
floatCountHandle.Record(cc.MetricsRecorder(), 2, "float counter label val", "float counter optional label val")
intHistoHandle.Record(cc.MetricsRecorder(), 3, "int histo label val", "int histo optional label val")
floatHistoHandle.Record(cc.MetricsRecorder(), 4, "float histo label val", "float histo optional label val")
intGaugeHandle.Record(cc.MetricsRecorder(), 5, "int gauge label val", "int gauge optional label val")
return &recordingLoadBalancer{
Balancer: balancer.Get(pickfirst.Name).Build(cc, bOpts),
}
}
type recordingLoadBalancer struct {
balancer.Balancer
}
// TestMetricsRecorderList tests the metrics recorder list functionality of the
// ClientConn. It configures a global and local stats handler Dial Option. These
// stats handlers implement the MetricsRecorder interface. It also configures a
// balancer which registers metrics and records on metrics at build time. This
// test then asserts that the recorded metrics show up on both configured stats
// handlers.
func (s) TestMetricsRecorderList(t *testing.T) {
cleanup := internal.SnapshotMetricRegistryForTesting()
defer cleanup()
mr := manual.NewBuilderWithScheme("test-metrics-recorder-list")
defer mr.Close()
json := `{"loadBalancingConfig": [{"recording_load_balancer":{}}]}`
sc := internal.ParseServiceConfig.(func(string) *serviceconfig.ParseResult)(json)
mr.InitialState(resolver.State{
ServiceConfig: sc,
})
// Create two stats.Handlers which also implement MetricsRecorder, configure
// one as a global dial option and one as a local dial option.
mr1 := stats.NewTestMetricsRecorder()
mr2 := stats.NewTestMetricsRecorder()
defer internal.ClearGlobalDialOptions()
internal.AddGlobalDialOptions.(func(opt ...grpc.DialOption))(grpc.WithStatsHandler(mr1))
cc, err := grpc.NewClient(mr.Scheme()+":///", grpc.WithResolvers(mr), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithStatsHandler(mr2))
if err != nil {
t.Fatalf("grpc.NewClient() failed: %v", err)
}
defer cc.Close()
tsc := testgrpc.NewTestServiceClient(cc)
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Trigger the recording_load_balancer to build, which will trigger metrics
// to record.
tsc.UnaryCall(ctx, &testpb.SimpleRequest{})
mdWant := stats.MetricsData{
Handle: intCountHandle.Descriptor(),
IntIncr: 1,
LabelKeys: []string{"int counter label", "int counter optional label"},
LabelVals: []string{"int counter label val", "int counter optional label val"},
}
if err := mr1.WaitForInt64Count(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
if err := mr2.WaitForInt64Count(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
mdWant = stats.MetricsData{
Handle: floatCountHandle.Descriptor(),
FloatIncr: 2,
LabelKeys: []string{"float counter label", "float counter optional label"},
LabelVals: []string{"float counter label val", "float counter optional label val"},
}
if err := mr1.WaitForFloat64Count(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
if err := mr2.WaitForFloat64Count(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
mdWant = stats.MetricsData{
Handle: intHistoHandle.Descriptor(),
IntIncr: 3,
LabelKeys: []string{"int histo label", "int histo optional label"},
LabelVals: []string{"int histo label val", "int histo optional label val"},
}
if err := mr1.WaitForInt64Histo(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
if err := mr2.WaitForInt64Histo(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
mdWant = stats.MetricsData{
Handle: floatHistoHandle.Descriptor(),
FloatIncr: 4,
LabelKeys: []string{"float histo label", "float histo optional label"},
LabelVals: []string{"float histo label val", "float histo optional label val"},
}
if err := mr1.WaitForFloat64Histo(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
if err := mr2.WaitForFloat64Histo(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
mdWant = stats.MetricsData{
Handle: intGaugeHandle.Descriptor(),
IntIncr: 5,
LabelKeys: []string{"int gauge label", "int gauge optional label"},
LabelVals: []string{"int gauge label val", "int gauge optional label val"},
}
if err := mr1.WaitForInt64Gauge(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
if err := mr2.WaitForInt64Gauge(ctx, mdWant); err != nil {
t.Fatal(err.Error())
}
}
// TestMetricRecorderListPanic tests that the metrics recorder list panics if
// received the wrong number of labels for a particular metric.
func (s) TestMetricRecorderListPanic(t *testing.T) {
cleanup := internal.SnapshotMetricRegistryForTesting()
defer cleanup()
intCountHandle := estats.RegisterInt64Count(estats.MetricDescriptor{
Name: "simple counter",
Description: "sum of all emissions from tests",
Unit: "int",
Labels: []string{"int counter label"},
OptionalLabels: []string{"int counter optional label"},
Default: false,
})
mrl := istats.NewMetricsRecorderList(nil)
want := `Received 1 labels in call to record metric "simple counter", but expected 2.`
defer func() {
if r := recover(); !strings.Contains(fmt.Sprint(r), want) {
t.Errorf("expected panic contains %q, got %q", want, r)
}
}()
intCountHandle.Record(mrl, 1, "only one label")
}
// TestMetricsRecorderList_RegisterAsyncReporter verifies that the list implementation
// correctly fans out registration calls to all underlying recorders and
// aggregates the cleanup calls.
func (s) TestMetricsRecorderList_RegisterAsyncReporter(t *testing.T) {
spy1 := &spyMetricsRecorder{name: "spy1"}
spy2 := &spyMetricsRecorder{name: "spy2"}
spy3 := &spyMetricsRecorder{name: "spy3"}
list := istats.NewMetricsRecorderList([]gstats.Handler{spy1, spy2, spy3})
desc := &estats.MetricDescriptor{Name: "test_metric", Description: "test"}
mockMetric := &mockAsyncMetric{d: desc}
dummyReporter := estats.AsyncMetricReporterFunc(func(estats.AsyncMetricsRecorder) error {
return nil
})
cleanup := list.RegisterAsyncReporter(dummyReporter, mockMetric)
// Check that RegisterAsyncReporter was called exactly once on ALL spies
if spy1.registerCalledCount != 1 {
t.Errorf("spy1 register called %d times, want 1", spy1.registerCalledCount)
}
if spy2.registerCalledCount != 1 {
t.Errorf("spy2 register called %d times, want 1", spy2.registerCalledCount)
}
if spy3.registerCalledCount != 1 {
t.Errorf("spy3 register called %d times, want 1", spy3.registerCalledCount)
}
// Verify that cleanup has NOT been called yet
if spy1.cleanupCalledCount != 0 {
t.Error("spy1 cleanup called prematurely")
}
cleanup()
// Check that the cleanup function returned by the list actually triggers
// the cleanup logic on ALL underlying spies.
if spy1.cleanupCalledCount != 1 {
t.Errorf("spy1 cleanup called %d times, want 1", spy1.cleanupCalledCount)
}
if spy2.cleanupCalledCount != 1 {
t.Errorf("spy2 cleanup called %d times, want 1", spy2.cleanupCalledCount)
}
if spy3.cleanupCalledCount != 1 {
t.Errorf("spy3 cleanup called %d times, want 1", spy3.cleanupCalledCount)
}
}
// --- Helpers & Spies ---
// mockAsyncMetric implements estats.AsyncMetric
type mockAsyncMetric struct {
estats.AsyncMetric
d *estats.MetricDescriptor
}
func (m *mockAsyncMetric) Descriptor() *estats.MetricDescriptor {
return m.d
}
// spyMetricsRecorder implements estats.MetricsRecorder
type spyMetricsRecorder struct {
stats.TestMetricsRecorder
name string
registerCalledCount int
cleanupCalledCount int
}
// RegisterAsyncReporter implements the interface and tracks calls.
func (s *spyMetricsRecorder) RegisterAsyncReporter(estats.AsyncMetricReporter, ...estats.AsyncMetric) func() {
s.registerCalledCount++
// Return a cleanup function that tracks if it was called
return func() {
s.cleanupCalledCount++
}
}

70
internal/stats/stats.go Normal file
View File

@@ -0,0 +1,70 @@
/*
*
* Copyright 2025 gRPC authors.
*
* 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 stats
import (
"context"
"google.golang.org/grpc/stats"
)
type combinedHandler struct {
handlers []stats.Handler
}
// NewCombinedHandler combines multiple stats.Handlers into a single handler.
//
// It returns nil if no handlers are provided. If only one handler is
// provided, it is returned directly without wrapping.
func NewCombinedHandler(handlers ...stats.Handler) stats.Handler {
switch len(handlers) {
case 0:
return nil
case 1:
return handlers[0]
default:
return &combinedHandler{handlers: handlers}
}
}
func (ch *combinedHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context {
for _, h := range ch.handlers {
ctx = h.TagRPC(ctx, info)
}
return ctx
}
func (ch *combinedHandler) HandleRPC(ctx context.Context, stats stats.RPCStats) {
for _, h := range ch.handlers {
h.HandleRPC(ctx, stats)
}
}
func (ch *combinedHandler) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context {
for _, h := range ch.handlers {
ctx = h.TagConn(ctx, info)
}
return ctx
}
func (ch *combinedHandler) HandleConn(ctx context.Context, stats stats.ConnStats) {
for _, h := range ch.handlers {
h.HandleConn(ctx, stats)
}
}