diff --git a/v2/invoke.go b/v2/invoke.go index 594ac168..7975dae3 100644 --- a/v2/invoke.go +++ b/v2/invoke.go @@ -77,7 +77,7 @@ func Sleep(ctx context.Context, d time.Duration) error { type sleeper func(ctx context.Context, d time.Duration) error // invoke implements Invoke, taking an additional sleeper argument for testing. -func invoke(ctx context.Context, call APICall, settings CallSettings, sp sleeper) error { +func invoke(ctx context.Context, call APICall, settings CallSettings, sp sleeper) (err error) { var retryer Retryer // Only use the value provided via WithTimeout if the context doesn't @@ -89,6 +89,13 @@ func invoke(ctx context.Context, call APICall, settings CallSettings, sp sleeper ctx = c } + if IsFeatureEnabled("METRICS") { + start := time.Now() + defer func() { + recordMetric(ctx, settings, time.Since(start), err) + }() + } + retryCount := 0 // Feature gate: GOOGLE_SDK_GO_EXPERIMENTAL_TRACING=true tracingEnabled := IsFeatureEnabled("TRACING") @@ -97,7 +104,7 @@ func invoke(ctx context.Context, call APICall, settings CallSettings, sp sleeper if tracingEnabled { ctxToUse = withRetryCount(ctx, retryCount) } - err := call(ctxToUse, settings) + err = call(ctxToUse, settings) if err == nil { return nil } diff --git a/v2/invoke_otel_test.go b/v2/invoke_otel_test.go new file mode 100644 index 00000000..6624b61f --- /dev/null +++ b/v2/invoke_otel_test.go @@ -0,0 +1,218 @@ +// Copyright 2026, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package gax + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/gax-go/v2/callctx" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type testRetryer struct { + count int +} + +func (r *testRetryer) Retry(err error) (time.Duration, bool) { + if r.count == 0 { + r.count++ + return 50 * time.Millisecond, true + } + return 0, false +} + +func TestInvokeWithMetrics(t *testing.T) { + t.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_METRICS", "true") + TestOnlyResetIsFeatureEnabled() + defer TestOnlyResetIsFeatureEnabled() + + tests := []struct { + name string + setupCtx func() (context.Context, context.CancelFunc) + callFunc func(context.Context, CallSettings) error + callOpts []CallOption + wantDataAttr map[string]string + wantErr bool + minSum float64 + }{ + { + name: "success", + setupCtx: func() (context.Context, context.CancelFunc) { + return context.Background(), func() {} + }, + callFunc: func(ctx context.Context, settings CallSettings) error { + time.Sleep(10 * time.Millisecond) + return nil + }, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "OK", + }, + wantErr: false, + minSum: 0.01, + }, + { + name: "retry_with_backoff", + setupCtx: func() (context.Context, context.CancelFunc) { + return context.Background(), func() {} + }, + callOpts: []CallOption{ + WithRetry(func() Retryer { return &testRetryer{} }), + }, + callFunc: func(ctx context.Context, settings CallSettings) error { + return status.Error(codes.DeadlineExceeded, "deadline exceeded") // Trigger retry, eventually failing after 1 retry + }, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "error.type": "DEADLINE_EXCEEDED", + "rpc.response.status_code": "DEADLINE_EXCEEDED", + }, + wantErr: true, + minSum: 0.05, // The retryer sleeps for 50ms, so duration must be at least 0.05s + }, + { + name: "error_attributes", + setupCtx: func() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 1*time.Millisecond) + }, + callFunc: func(ctx context.Context, settings CallSettings) error { + <-ctx.Done() + return ctx.Err() + }, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "error.type": "CLIENT_TIMEOUT", + "rpc.response.status_code": "DEADLINE_EXCEEDED", + }, + wantErr: true, + }, + { + name: "metadata_attributes", + setupCtx: func() (context.Context, context.CancelFunc) { + ctx := callctx.WithTelemetryContext(context.Background(), "rpc_method", "my_method", "url_template", "/v1/foo") + return ctx, func() {} + }, + callFunc: func(ctx context.Context, settings CallSettings) error { + return nil + }, + wantDataAttr: map[string]string{ + "url.domain": "test.domain", + "rpc.system.name": "grpc", + "rpc.response.status_code": "OK", + "rpc.method": "my_method", + "url.template": "/v1/foo", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := tt.setupCtx() + defer cancel() + + reader := metric.NewManualReader() + provider := metric.NewMeterProvider(metric.WithReader(reader)) + + opts := []TelemetryOption{ + WithMeterProvider(provider), + WithTelemetryAttributes(map[string]string{ + URLDomain: "test.domain", + RPCSystem: "grpc", + }), + } + cm := NewClientMetrics(opts...) + + callOpts := []CallOption{WithClientMetrics(cm)} + if tt.callOpts != nil { + callOpts = append(callOpts, tt.callOpts...) + } + + err := Invoke(ctx, tt.callFunc, callOpts...) + if (err != nil) != tt.wantErr { + t.Fatalf("Invoke() error = %v, wantErr %v", err, tt.wantErr) + } + + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("failed to collect metrics: %v", err) + } + + if len(rm.ScopeMetrics) == 0 { + t.Fatalf("expected at least 1 ScopeMetrics") + } + + scopeMetric := rm.ScopeMetrics[0] + if len(scopeMetric.Metrics) == 0 { + t.Fatalf("expected at least 1 Metric recorded") + } + + m := scopeMetric.Metrics[0] + if m.Name != metricName { + t.Errorf("expected metric.Name %q, got %q", metricName, m.Name) + } + + histo, ok := m.Data.(metricdata.Histogram[float64]) + if !ok { + t.Fatalf("expected metricdata.Histogram[float64], got %T", m.Data) + } + + if len(histo.DataPoints) == 0 { + t.Fatalf("expected at least 1 DataPoint") + } + + point := histo.DataPoints[0] + if point.Count != 1 { + t.Errorf("expected count 1, got %d", point.Count) + } + if point.Sum < tt.minSum { + t.Errorf("expected sum >= %f, got %f", tt.minSum, point.Sum) + } + + gotDataAttr := make(map[string]string) + for _, a := range point.Attributes.ToSlice() { + gotDataAttr[string(a.Key)] = a.Value.AsString() + } + + if diff := cmp.Diff(tt.wantDataAttr, gotDataAttr); diff != "" { + t.Errorf("DataPoint attributes mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/v2/telemetry.go b/v2/telemetry.go index a22ab532..f3be81d4 100644 --- a/v2/telemetry.go +++ b/v2/telemetry.go @@ -375,6 +375,9 @@ func ExtractTelemetryErrorInfo(ctx context.Context, err error) TelemetryErrorInf } st, ok := status.FromError(err) + if !ok { + st = status.FromContextError(err) + } rpcStatusCode := grpcCodeToStatusString(st.Code()) var errType string diff --git a/v2/telemetry_test.go b/v2/telemetry_test.go index 6a87604f..b5b92946 100644 --- a/v2/telemetry_test.go +++ b/v2/telemetry_test.go @@ -367,7 +367,7 @@ func TestExtractTelemetryErrorInfo(t *testing.T) { err: context.Canceled, wantInfo: TelemetryErrorInfo{ ErrorType: "CLIENT_CANCELLED", - StatusCode: "UNKNOWN", + StatusCode: "CANCELED", StatusMessage: "context canceled", }, }, @@ -380,7 +380,7 @@ func TestExtractTelemetryErrorInfo(t *testing.T) { err: context.DeadlineExceeded, wantInfo: TelemetryErrorInfo{ ErrorType: "CLIENT_TIMEOUT", - StatusCode: "UNKNOWN", + StatusCode: "DEADLINE_EXCEEDED", StatusMessage: "context deadline exceeded", }, },