blob: ac2cc7ce6c0ac6b2719d2c943405248e299c4075 [file] [edit]
/*
Copyright 2024 Google LLC
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 bigtable
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"reflect"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
btpb "cloud.google.com/go/bigtable/apiv2/bigtablepb"
metrics "cloud.google.com/go/bigtable/internal/metrics"
"cloud.google.com/go/bigtable/internal/metricstest"
"cloud.google.com/go/internal/testutil"
"github.com/google/go-cmp/cmp/cmpopts"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/api/option"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/wrapperspb"
"cloud.google.com/go/bigtable/bttest"
"google.golang.org/grpc/metadata"
)
// bigtableReadRowsServerWrapper wraps a generic grpc.ServerStream to implement the
// btpb.Bigtable_ReadRowsServer interface, specifically the Send(*ReadRowsResponse) error method.
type bigtableReadRowsServerWrapper struct {
grpc.ServerStream
}
func (x *bigtableReadRowsServerWrapper) Send(m *btpb.ReadRowsResponse) error {
return x.ServerStream.SendMsg(m)
}
var (
clusterID1 = "cluster-id-1"
clusterID2 = "cluster-id-2"
zoneID1 = "zone-id-1"
testHeaders, _ = proto.Marshal(&btpb.ResponseParams{
ClusterId: &clusterID1,
ZoneId: &zoneID1,
})
testTrailers, _ = proto.Marshal(&btpb.ResponseParams{
ClusterId: &clusterID2,
ZoneId: &zoneID1,
})
testHeaderMD = &metadata.MD{
metrics.LocationMDKey: []string{string(testHeaders)},
metrics.ServerTimingMDKey: []string{"gfet4t7; dur=1234"},
}
testTrailerMD = &metadata.MD{
metrics.LocationMDKey: []string{string(testTrailers)},
metrics.ServerTimingMDKey: []string{"gfet4t7; dur=5678"},
}
sleepDurationForTest = 200 * time.Millisecond // Used in ReadRowsWithDelay
)
// ReadRowsWithDelay implements the core logic for a ReadRows RPC that introduces a delay.
// This is designed to be used within a gRPC stream interceptor.
func ReadRowsWithDelay(_ any, stream btpb.Bigtable_ReadRowsServer) error {
// 1. Send headers immediately
if err := stream.SendHeader(metadata.MD{
metrics.ServerTimingMDKey: []string{"gfet4t7; dur=10"}, // Small initial server latency
metrics.LocationMDKey: []string{string(testHeaders)},
}); err != nil {
return err
}
// 2. Send first chunk/response
if err := stream.Send(&btpb.ReadRowsResponse{
Chunks: []*btpb.ReadRowsResponse_CellChunk{
{
RowKey: []byte("row1"),
FamilyName: &wrapperspb.StringValue{Value: "cf"},
Qualifier: &wrapperspb.BytesValue{Value: []byte("q1")},
Value: []byte("val1"),
RowStatus: &btpb.ReadRowsResponse_CellChunk_CommitRow{CommitRow: true},
},
},
}); err != nil {
return err
}
// 3. Sleep
time.Sleep(sleepDurationForTest)
// 4. Send second chunk/response
if err := stream.Send(&btpb.ReadRowsResponse{
Chunks: []*btpb.ReadRowsResponse_CellChunk{
{
RowKey: []byte("row2"),
FamilyName: &wrapperspb.StringValue{Value: "cf"},
Qualifier: &wrapperspb.BytesValue{Value: []byte("q2")},
Value: []byte("val2"),
RowStatus: &btpb.ReadRowsResponse_CellChunk_CommitRow{CommitRow: true},
},
},
}); err != nil {
return err
}
return nil // Indicates successful end of stream
}
// setupFakeServerWithCustomHandler sets up a fake server with a custom stream handler for ReadRows.
// It returns a configured Table, a cleanup function, and any error during setup.
func setupFakeServerWithCustomHandler(projectID, instanceID string, cfg ClientConfig,
customReadRowsHandler func(srv any, stream btpb.Bigtable_ReadRowsServer) error) (*Table, func(), error) {
streamInterceptor := func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if info.FullMethod == "/google.bigtable.v2.Bigtable/ReadRows" {
// req is nil because ReadRowsWithDelay does not use the request argument.
// Wrap the generic ServerStream with our specific wrapper.
wrappedStream := &bigtableReadRowsServerWrapper{ss}
return customReadRowsHandler(srv, wrappedStream)
}
return handler(srv, ss) // Default handling for other methods
}
rawGrpcServer, err := bttest.NewServer("localhost:0", grpc.StreamInterceptor(streamInterceptor))
if err != nil {
return nil, nil, fmt.Errorf("failed to start bttest server: %w", err)
}
conn, err := grpc.Dial(rawGrpcServer.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), grpc.WithStatsHandler(metrics.SharedStatsHandler))
if err != nil {
rawGrpcServer.Close()
return nil, nil, fmt.Errorf("failed to dial test server: %w", err)
}
clientOpts := []option.ClientOption{option.WithGRPCConn(conn)}
ctx := context.Background()
client, err := NewClientWithConfig(ctx, projectID, instanceID, cfg, clientOpts...)
if err != nil {
conn.Close()
rawGrpcServer.Close()
return nil, nil, fmt.Errorf("failed to create client: %w", err)
}
tbl := client.Open("test-table")
cleanup := func() {
client.Close()
conn.Close()
rawGrpcServer.Close()
}
return tbl, cleanup, nil
}
// sendTwoRowsHandler is a simple server-side stream handler that sends two predefined rows.
func sendTwoRowsHandler(_ any, stream btpb.Bigtable_ReadRowsServer) error {
// 1. Send headers immediately
if err := stream.SendHeader(metadata.MD{
metrics.LocationMDKey: []string{string(testHeaders)}, // Send cluster/zone info
}); err != nil {
return err
}
// 2. Send first chunk/response
if err := stream.Send(&btpb.ReadRowsResponse{
Chunks: []*btpb.ReadRowsResponse_CellChunk{
{
RowKey: []byte("row1"),
FamilyName: &wrapperspb.StringValue{Value: "cf"},
Qualifier: &wrapperspb.BytesValue{Value: []byte("q1")},
Value: []byte("val1"),
RowStatus: &btpb.ReadRowsResponse_CellChunk_CommitRow{CommitRow: true},
},
},
}); err != nil {
return err
}
// 3. Send second chunk/response
if err := stream.Send(&btpb.ReadRowsResponse{
Chunks: []*btpb.ReadRowsResponse_CellChunk{
{
RowKey: []byte("row2"),
FamilyName: &wrapperspb.StringValue{Value: "cf"},
Qualifier: &wrapperspb.BytesValue{Value: []byte("q2")},
Value: []byte("val2"),
RowStatus: &btpb.ReadRowsResponse_CellChunk_CommitRow{CommitRow: true},
},
},
}); err != nil {
return err
}
return nil
}
func TestNewBuiltinMetricsTracerFactory(t *testing.T) {
ctx := context.Background()
project := "test-project"
instance := "test-instance"
appProfile := "test-app-profile"
clientUID := "test-uid"
wantMetricNamesStdout := []string{
metrics.MetricNameAttemptLatencies, metrics.MetricNameAttemptLatencies,
metrics.MetricNameAttemptLatencies2, metrics.MetricNameAttemptLatencies2,
metrics.MetricNameFirstRespLatencies,
metrics.MetricNameConnErrCount, metrics.MetricNameConnErrCount,
metrics.MetricNameOperationLatencies,
metrics.MetricNameRetryCount,
metrics.MetricNameServerLatencies,
metrics.MetricNameClientBlockingLatencies, metrics.MetricNameClientBlockingLatencies,
metrics.MetricNameAppBlockingLatencies,
}
wantMetricTypesGCM := []string{}
for _, wantMetricName := range wantMetricNamesStdout {
wantMetricTypesGCM = append(wantMetricTypesGCM, metrics.BuiltInMetricsMeterName+wantMetricName)
}
sort.Strings(wantMetricTypesGCM)
// Reduce sampling period to reduce test run time
origSamplePeriod := metrics.DefaultSamplePeriod
metrics.DefaultSamplePeriod = 500 * time.Millisecond
defer func() {
metrics.DefaultSamplePeriod = origSamplePeriod
}()
// return constant client UID instead of random, so that attributes can be compared
origGenerateClientUID := metrics.GenerateClientUID
metrics.GenerateClientUID = func() (string, error) {
return clientUID, nil
}
defer func() {
metrics.GenerateClientUID = origGenerateClientUID
}()
// Setup mock monitoring server
monitoringServer, err := metricstest.NewServer()
if err != nil {
t.Fatalf("Error setting up metrics test server")
}
go monitoringServer.Serve()
defer monitoringServer.Shutdown()
// Override exporter options
origCreateExporterOptions := metrics.CreateExporterOptions
metrics.CreateExporterOptions = func(opts ...option.ClientOption) []option.ClientOption {
return []option.ClientOption{
option.WithEndpoint(monitoringServer.Endpoint), // Connect to mock
option.WithoutAuthentication(),
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
}
}
defer func() {
metrics.CreateExporterOptions = origCreateExporterOptions
}()
// Setup fake Bigtable server
isFirstAttempt := true
receivedHeader := metadata.MD{}
serverStreamInterceptor := func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
// Capture incoming metadata
receivedHeader, _ = metadata.FromIncomingContext(ss.Context())
if strings.HasSuffix(info.FullMethod, "ReadRows") {
if isFirstAttempt {
// Fail first attempt
isFirstAttempt = false
return status.Error(codes.Unavailable, "Mock Unavailable error")
}
// Send server headers
header := metadata.New(map[string]string{
metrics.ServerTimingMDKey: "gfet4t7; dur=123",
metrics.LocationMDKey: string(testHeaders),
})
ss.SendHeader(header)
}
return handler(srv, ss)
}
tests := []struct {
desc string
config ClientConfig
wantBuiltinEnabled bool
setEmulator bool
wantCreateTSCallsCount int // No. of CreateTimeSeries calls
wantClientAttributes []attribute.KeyValue
}{
{
desc: "should create a new tracer factory with default meter provider",
config: ClientConfig{AppProfile: appProfile},
wantBuiltinEnabled: true,
wantCreateTSCallsCount: 2,
wantClientAttributes: []attribute.KeyValue{
attribute.String(metrics.MetricLabelKeyProject, project),
attribute.String(metrics.MetricLabelKeyInstance, instance),
attribute.String(metrics.MetricLabelKeyAppProfile, appProfile),
attribute.String(metrics.MetricLabelKeyClientUID, clientUID),
attribute.String(metrics.MetricLabelKeyClientName, metrics.ClientName()),
},
},
{
desc: "should create a new tracer factory with noop meter provider",
config: ClientConfig{MetricsProvider: NoopMetricsProvider{}, AppProfile: appProfile},
},
{
desc: "should not create instruments when BIGTABLE_EMULATOR_HOST is set",
config: ClientConfig{AppProfile: appProfile},
setEmulator: true,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
if test.setEmulator {
// Set environment variable
t.Setenv("BIGTABLE_EMULATOR_HOST", "localhost:8086")
}
// open table and compare errors
tbl, cleanup, gotErr := setupFakeServer(project, instance, test.config, grpc.StreamInterceptor(serverStreamInterceptor))
defer cleanup()
if gotErr != nil {
t.Fatalf("err: got: %v, want: %v", gotErr, nil)
return
}
gotClient := tbl.c
if gotClient.metricsTracerFactory.Enabled != test.wantBuiltinEnabled {
t.Errorf("builtinEnabled: got: %v, want: %v", gotClient.metricsTracerFactory.Enabled, test.wantBuiltinEnabled)
}
if !equalsKeyValue(gotClient.metricsTracerFactory.ClientAttributes(), test.wantClientAttributes) {
t.Errorf("clientAttributes: got: %+v, want: %+v", gotClient.metricsTracerFactory.ClientAttributes(), test.wantClientAttributes)
}
// Check instruments
if got := gotClient.metricsTracerFactory.HasInstruments(); got != test.wantBuiltinEnabled {
t.Errorf("NonNilInstruments: got: %v, want: %v", got, test.wantBuiltinEnabled)
}
// record start time
testStartTime := time.Now()
// pop out all old requests
monitoringServer.CreateServiceTimeSeriesRequests()
// Perform read rows operation
isFirstAttempt = true
err := tbl.ReadRows(ctx, NewRange("a", "z"), func(r Row) bool {
return true
})
if err != nil {
t.Fatalf("ReadRows failed: %v", err)
}
// Check feature flags
ffStrs := receivedHeader.Get(featureFlagsHeaderKey)
if len(ffStrs) < 1 {
t.Errorf("Feature flags not sent by client")
}
ffBytes, err := base64.URLEncoding.DecodeString(ffStrs[0])
if err != nil {
t.Errorf("Feature flags not encoded correctly: %v", err)
}
ff := &btpb.FeatureFlags{}
if err = proto.Unmarshal(ffBytes, ff); err != nil {
t.Errorf("Feature flags not marshalled correctly: %v", err)
}
if ff.ClientSideMetricsEnabled != test.wantBuiltinEnabled || !ff.LastScannedRowResponses || !ff.ReverseScans {
t.Errorf("Feature flags: ClientSideMetricsEnabled got: %v, want: %v\n"+
"LastScannedRowResponses got: %v, want: %v\n"+
"ReverseScans got: %v, want: %v\n",
ff.ClientSideMetricsEnabled, test.wantBuiltinEnabled,
ff.LastScannedRowResponses, true,
ff.ReverseScans, true,
)
}
// Calculate elapsed time
elapsedTime := time.Since(testStartTime)
if elapsedTime < 3*metrics.DefaultSamplePeriod {
// Ensure at least 2 datapoints are recorded
time.Sleep(3*metrics.DefaultSamplePeriod - elapsedTime)
}
// Get new CreateServiceTimeSeriesRequests
gotCreateTSCalls := monitoringServer.CreateServiceTimeSeriesRequests()
for _, gotCreateTSCall := range gotCreateTSCalls {
gotMetricTypes := []string{}
for _, ts := range gotCreateTSCall.TimeSeries {
// ts.Metric.Type is of the form "bigtable.googleapis.com/internal/client/server_latencies"
gotMetricTypes = append(gotMetricTypes, ts.Metric.Type)
// Assert "streaming" metric label is correct
gotStreaming, gotStreamingExists := ts.Metric.Labels[metrics.MetricLabelKeyStreamingOperation]
splitMetricType := strings.Split(ts.Metric.Type, "/")
internalMetricName := splitMetricType[len(splitMetricType)-1] // server_latencies
wantStreamingExists := slices.Contains(metrics.MetricsDetails[internalMetricName].AdditionalAttrs(), metrics.MetricLabelKeyStreamingOperation)
if wantStreamingExists && (!gotStreamingExists || gotStreaming != "true") {
t.Errorf("Metric label key: %s, value: got: %v, want: %v", metrics.MetricLabelKeyStreamingOperation, gotStreaming, "true")
}
if !wantStreamingExists && gotStreamingExists {
t.Errorf("Metric label key: %s exists, value: got: %v, want: %v", metrics.MetricLabelKeyStreamingOperation, gotStreamingExists, wantStreamingExists)
}
// Assert "method" metric label is correct
wantMethod := "Bigtable.ReadRows"
if gotLabel, ok := ts.Metric.Labels[metrics.MetricLabelKeyMethod]; !ok || gotLabel != wantMethod {
t.Errorf("Metric label key: %s, value: got: %v, want: %v", metrics.MetricLabelKeyMethod, gotLabel, wantMethod)
}
}
sort.Strings(gotMetricTypes)
if !testutil.Equal(gotMetricTypes, wantMetricTypesGCM) {
t.Errorf("Metric types missing in req. \ngot: %v, \nwant: %v\ndiff: %v", gotMetricTypes, wantMetricTypesGCM, testutil.Diff(gotMetricTypes, wantMetricTypesGCM))
}
}
gotCreateTSCallsCount := len(gotCreateTSCalls)
if gotCreateTSCallsCount < test.wantCreateTSCallsCount {
t.Errorf("No. of CreateServiceTimeSeriesRequests: got: %v, want: %v", gotCreateTSCalls, test.wantCreateTSCallsCount)
}
})
}
}
func TestConnectivityErrorCount(t *testing.T) {
ctx := context.Background()
project := "test-project"
instance := "test-instance"
appProfile := "test-app-profile"
// Increase sampling period to simulate potential delays
origSamplePeriod := metrics.DefaultSamplePeriod
metrics.DefaultSamplePeriod = 500 * time.Millisecond
defer func() {
metrics.DefaultSamplePeriod = origSamplePeriod
}()
// Setup mock monitoring server
monitoringServer, err := metricstest.NewServer()
if err != nil {
t.Fatalf("Error setting up metrics test server: %v", err)
}
go monitoringServer.Serve()
defer monitoringServer.Shutdown()
// Override exporter options to connect to the mock server
origCreateExporterOptions := metrics.CreateExporterOptions
metrics.CreateExporterOptions = func(opts ...option.ClientOption) []option.ClientOption {
return []option.ClientOption{
option.WithEndpoint(monitoringServer.Endpoint),
option.WithoutAuthentication(),
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
}
}
defer func() {
metrics.CreateExporterOptions = origCreateExporterOptions
}()
// Control structure for mock server behavior during the specific ReadRows call.
// We use a channel to signal the interceptor that the ReadRows call under test is active.
readRowsCallActive := make(chan bool, 1)
var testSpecificAttemptCount int32
serverStreamInterceptor := func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if strings.HasSuffix(info.FullMethod, "ReadRows") {
select {
case <-readRowsCallActive:
currentTestAttempt := atomic.AddInt32(&testSpecificAttemptCount, 1)
if currentTestAttempt == 1 {
// Put the token back for subsequent retries of this specific call.
readRowsCallActive <- true
return status.Error(codes.Unavailable, "Mock Unavailable error for connectivity test")
}
if currentTestAttempt == 2 {
header := metadata.New(map[string]string{
metrics.LocationMDKey: string(testHeaders),
})
if errH := ss.SendHeader(header); errH != nil {
t.Errorf("[ServerInterceptor Attempt 2] Error sending header: %v", errH)
}
// Send a minimal successful message to ensure headers are processed by the client.
emptyResp := &btpb.ReadRowsResponse{}
if errS := ss.SendMsg(emptyResp); errS != nil {
t.Errorf("[ServerInterceptor Attempt 2] Error sending empty message: %v", errS)
return status.Errorf(codes.Internal, "mock server failed to send empty message: %v", errS)
}
readRowsCallActive <- true
return status.Error(codes.Unavailable, "Mock Unavailable error with location headers")
}
// On the third and final attempt, cause a non-retriable error.
atomic.StoreInt32(&testSpecificAttemptCount, 0)
// Do not put the token back, as this is the final attempt for this ReadRows sequence.
return status.Error(codes.Internal, "non-retriable error")
default:
return handler(srv, ss)
}
}
return handler(srv, ss)
}
config := ClientConfig{AppProfile: appProfile}
tbl, cleanup, gotErr := setupFakeServer(project, instance, config, grpc.StreamInterceptor(serverStreamInterceptor))
defer cleanup()
if gotErr != nil {
t.Fatalf("setupFakeServer error: got: %v, want: nil", gotErr)
}
// Pop out any old requests from the monitoring server to ensure a clean state.
monitoringServer.CreateServiceTimeSeriesRequests()
atomic.StoreInt32(&testSpecificAttemptCount, 0)
readRowsCallActive <- true
// Perform a read rows operation that will undergo a specific retry sequence:
// Attempt 1: Fails with Unavailable (no server headers) -> conn error count = 1
// Attempt 2: Fails with Unavailable (with location header) -> conn error count = 0
// Attempt 3: Fails with Internal (no server headers) -> conn error count = 1
// The overall operation fails with the final Internal error.
err = tbl.ReadRows(ctx, NewRange("a", "z"), func(r Row) bool { return true })
if err == nil {
t.Fatal("ReadRows: got nil error, want an error")
}
if status.Code(err) != codes.Internal {
t.Fatalf("ReadRows: got error code %v, want %v", status.Code(err), codes.Internal)
}
// Wait a bit for metrics to be exported. The defaultSamplePeriod is 500ms,
// so waiting slightly longer should be sufficient.
// If tests are flaky, this might need adjustment or a more sophisticated wait.
time.Sleep(metrics.DefaultSamplePeriod + 200*time.Millisecond)
var totalConnectivityErrorsFromMetrics int64
statusesReported := make(map[string]int64)
foundConnErrMetricForTest := false
exportedMetricBatches := monitoringServer.CreateServiceTimeSeriesRequests()
for _, batch := range exportedMetricBatches {
for _, ts := range batch.TimeSeries {
if strings.HasSuffix(ts.Metric.Type, metrics.MetricNameConnErrCount) {
methodLabel, ok := ts.Metric.Labels[metrics.MetricLabelKeyMethod]
if !ok || methodLabel != "Bigtable.ReadRows" {
continue
}
foundConnErrMetricForTest = true
statusKey := ts.Metric.Labels[metrics.MetricLabelKeyStatus]
for _, point := range ts.Points {
// Summing up values from points. For a counter, this is the delta.
// We expect each reported error to be a single point with a value of 1.
statusesReported[statusKey] += point.GetValue().GetInt64Value()
totalConnectivityErrorsFromMetrics += point.GetValue().GetInt64Value()
}
}
}
}
if !foundConnErrMetricForTest {
t.Fatalf("Metric %s for method Bigtable.ReadRows was not found in exported metrics. Batches received: %+v", metrics.MetricNameConnErrCount, exportedMetricBatches)
}
if statusesReported[codes.Unavailable.String()] != 1 {
t.Errorf("Metric %s for status %s: got cumulative value %d, want 1. All statuses: %v",
metrics.MetricNameConnErrCount, codes.Unavailable.String(), statusesReported[codes.Unavailable.String()], statusesReported)
}
if statusesReported[codes.Internal.String()] != 1 {
t.Errorf("Metric %s for status %s: got cumulative value %d, want 1. All statuses: %v",
metrics.MetricNameConnErrCount, codes.Internal.String(), statusesReported[codes.Internal.String()], statusesReported)
}
// The total connectivity errors should be 2.
// Attempt 2 (Unavailable, with location) should not increment the error count.
if totalConnectivityErrorsFromMetrics != 2 {
t.Errorf("Metric %s: got cumulative value %d, want 2. Statuses reported: %v",
metrics.MetricNameConnErrCount, totalConnectivityErrorsFromMetrics, statusesReported)
}
}
func setMockErrorHandler(t *testing.T, mockErrorHandler *MockErrorHandler) {
origErrHandler := otel.GetErrorHandler()
otel.SetErrorHandler(mockErrorHandler)
t.Cleanup(func() {
otel.SetErrorHandler(origErrHandler)
})
}
func equalsKeyValue(gotAttrs, wantAttrs []attribute.KeyValue) bool {
if len(gotAttrs) != len(wantAttrs) {
return false
}
gotJSONVals, err := keyValueToKeyJSONValue(gotAttrs)
if err != nil {
return false
}
wantJSONVals, err := keyValueToKeyJSONValue(wantAttrs)
if err != nil {
return false
}
return testutil.Equal(gotJSONVals, wantJSONVals)
}
func keyValueToKeyJSONValue(attrs []attribute.KeyValue) (map[string]string, error) {
keyJSONVal := map[string]string{}
for _, attr := range attrs {
jsonVal, err := attr.Value.MarshalJSON()
if err != nil {
return nil, err
}
keyJSONVal[string(attr.Key)] = string(jsonVal)
}
return keyJSONVal, nil
}
func TestExporterLogs(t *testing.T) {
ctx := context.Background()
project := "test-project"
instance := "test-instance"
// Reduce sampling period to reduce test run time
origSamplePeriod := metrics.DefaultSamplePeriod
metrics.DefaultSamplePeriod = 500 * time.Millisecond
defer func() {
metrics.DefaultSamplePeriod = origSamplePeriod
}()
tbl, cleanup, gotErr := setupFakeServer(project, instance, ClientConfig{})
t.Cleanup(func() { defer cleanup() })
if gotErr != nil {
t.Fatalf("err: got: %v, want: %v", gotErr, nil)
return
}
// Set up mock error handler
mer := &MockErrorHandler{
buffer: new(bytes.Buffer),
}
setMockErrorHandler(t, mer)
// record start time
testStartTime := time.Now()
// Perform read rows operation
tbl.ReadRows(ctx, NewRange("a", "z"), func(r Row) bool {
return true
})
// Calculate elapsed time
elapsedTime := time.Since(testStartTime)
if elapsedTime < 3*metrics.DefaultSamplePeriod {
// Ensure at least 2 datapoints are recorded
time.Sleep(3*metrics.DefaultSamplePeriod - elapsedTime)
}
data, readErr := mer.read()
if readErr != nil {
t.Errorf("Failed to read errBuf: %v", readErr)
}
if !strings.Contains(data, metrics.MetricsErrorPrefix) {
t.Errorf("Expected %v to contain %v", data, metrics.MetricsErrorPrefix)
}
}
type MockErrorHandler struct {
buffer *bytes.Buffer
bufferMutex sync.Mutex
}
func (m *MockErrorHandler) Handle(err error) {
m.bufferMutex.Lock()
defer m.bufferMutex.Unlock()
fmt.Fprintln(m.buffer, err)
}
func (m *MockErrorHandler) read() (string, error) {
m.bufferMutex.Lock()
defer m.bufferMutex.Unlock()
data, err := io.ReadAll(m.buffer)
if err != nil {
return "", err
}
return string(data), nil
}
func TestToOtelMetricAttrs(t *testing.T) {
// Build a tracer via the factory so the private fields are populated
// consistently. Then stamp the fields the test cares about.
factory := &metrics.Factory{Enabled: true}
mt := factory.CreateTracer(context.Background(), "my-table", true)
mt.SetMethod("ReadRows")
mt.SetCurrOpStatus(codes.OK)
att := mt.CurrAttempt()
if att != nil {
att.SetStartTime(time.Now())
att.SetClusterID("my-cluster")
att.SetZoneID("my-zone")
}
tests := []struct {
desc string
metricName string
wantAttrs []attribute.KeyValue
wantError error
}{
{
desc: "Known metric",
metricName: metrics.MetricNameOperationLatencies,
wantAttrs: []attribute.KeyValue{
attribute.String(metrics.MetricLabelKeyTable, "my-table"),
attribute.String(metrics.MetricLabelKeyMethod, "ReadRows"),
attribute.Bool(metrics.MetricLabelKeyStreamingOperation, true),
attribute.String(metrics.MetricLabelKeyStatus, metrics.CanonicalString(codes.OK)),
attribute.String(metrics.MetricLabelKeyCluster, "my-cluster"),
attribute.String(metrics.MetricLabelKeyZone, "my-zone"),
},
wantError: nil,
},
{
desc: "Unknown metric",
metricName: "unknown_metric",
wantAttrs: []attribute.KeyValue{}, // Expect empty slice on error
wantError: fmt.Errorf("unable to create attributes list for unknown metric: unknown_metric"),
},
}
lessKeyValue := func(a, b attribute.KeyValue) bool { return a.Key < b.Key }
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
gotAttrSet, gotErr := mt.ToOtelMetricAttrs(test.metricName)
if !equalErrs(gotErr, test.wantError) {
t.Errorf("error got: %v, want: %v", gotErr, test.wantError)
}
gotAttrsSlice := gotAttrSet.ToSlice() // Convert Set to Slice
if gotAttrsSlice == nil { // Ensure nil slice is treated as empty slice for comparison
gotAttrsSlice = []attribute.KeyValue{}
}
if diff := testutil.Diff(gotAttrsSlice, test.wantAttrs,
cmpopts.IgnoreUnexported(attribute.KeyValue{}, attribute.Value{}),
cmpopts.SortSlices(lessKeyValue)); diff != "" {
t.Errorf("got=-, want=+ \n%v", diff)
}
})
}
}
func TestCreateExporterOptionsFiltering(t *testing.T) {
endpointOpt := option.WithEndpoint("test.endpoint")
apiKeyOpt := option.WithAPIKey("test.apikey")
audiencesOpt := option.WithAudiences("test.audience")
inputOpts := []option.ClientOption{
endpointOpt,
apiKeyOpt,
audiencesOpt,
}
filteredOpts := metrics.CreateExporterOptions(inputOpts...)
foundEndpointOpt := false
foundAPIKeyOpt := false
foundAudiencesOpt := false
for _, opt := range filteredOpts {
if reflect.TypeOf(opt) == reflect.TypeOf(endpointOpt) {
foundEndpointOpt = true
}
if reflect.TypeOf(opt) == reflect.TypeOf(apiKeyOpt) {
foundAPIKeyOpt = true
}
if reflect.TypeOf(opt) == reflect.TypeOf(audiencesOpt) {
foundAudiencesOpt = true
}
}
if foundEndpointOpt {
t.Errorf("option.WithEndpoint was found in filtered options, but it should have been filtered out")
}
if !foundAPIKeyOpt {
t.Errorf("option.WithAPIKey was not found in filtered options, but it should have been preserved")
}
if !foundAudiencesOpt {
t.Errorf("option.WithAudiences was not found in filtered options, but it should have been preserved")
}
if len(filteredOpts) != 2 {
t.Errorf("Expected 2 options to be returned, got %d", len(filteredOpts))
}
}
func TestFirstResponseLatencyWithDelayedStream(t *testing.T) {
ctx := context.Background()
project := "test-project"
instance := "test-instance"
appProfile := "test-app-profile"
clientUID := "test-uid-delayed"
// Reduce sampling period to reduce test run time
origSamplePeriod := metrics.DefaultSamplePeriod
metrics.DefaultSamplePeriod = 100 * time.Millisecond
defer func() {
metrics.DefaultSamplePeriod = origSamplePeriod
}()
// return constant client UID instead of random, so that attributes can be compared
origGenerateClientUID := metrics.GenerateClientUID
metrics.GenerateClientUID = func() (string, error) {
return clientUID, nil
}
defer func() {
metrics.GenerateClientUID = origGenerateClientUID
}()
// Set up a mock error handler to swallow expected shutdown errors
mer := &MockErrorHandler{buffer: new(bytes.Buffer)}
origErrHandler := otel.GetErrorHandler()
otel.SetErrorHandler(mer)
t.Cleanup(func() {
otel.SetErrorHandler(origErrHandler)
})
// Setup mock monitoring server
monitoringServer, err := metricstest.NewServer()
if err != nil {
t.Fatalf("Error setting up metrics test server: %v", err)
}
go monitoringServer.Serve()
defer monitoringServer.Shutdown()
// Override exporter options
origCreateExporterOptions := metrics.CreateExporterOptions
metrics.CreateExporterOptions = func(opts ...option.ClientOption) []option.ClientOption {
return []option.ClientOption{
option.WithEndpoint(monitoringServer.Endpoint),
option.WithoutAuthentication(),
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
}
}
defer func() {
metrics.CreateExporterOptions = origCreateExporterOptions
}()
// Setup fake Bigtable server with delayed stream handler
// Define the custom ReadRows handler that uses ReadRowsWithDelay
readRowsHandler := func(srv any, stream btpb.Bigtable_ReadRowsServer) error {
return ReadRowsWithDelay(srv, stream) // req can be nil if ReadRowsWithDelay is adapted
}
tbl, cleanup, err := setupFakeServerWithCustomHandler(project, instance, ClientConfig{AppProfile: appProfile}, readRowsHandler)
defer cleanup()
if err != nil {
t.Fatalf("setupFakeServerWithMock error: got: %v, want: nil", err)
return
}
// Pop out any old requests from other tests
monitoringServer.CreateServiceTimeSeriesRequests()
// Perform read rows operation
readErr := tbl.ReadRows(ctx, NewRange("a", "z"), func(r Row) bool {
// Consume all rows
return true
})
if readErr != nil {
t.Fatalf("ReadRows failed: %v", readErr)
}
// Allow time for metrics to be exported
// Wait for at least 3 export cycles to be reasonably sure metrics are flushed.
time.Sleep(metrics.DefaultSamplePeriod * 10)
// Fetch and analyze metrics
requests := monitoringServer.CreateServiceTimeSeriesRequests()
if len(requests) == 0 {
t.Fatalf("No CreateTimeSeriesRequests received from mock monitoring server")
}
var firstRespLatencyValue float64 = -1
var opLatencyValue float64 = -1
var foundMetricsForClientUID []string
for _, req := range requests {
for _, ts := range req.TimeSeries {
metricType := ts.GetMetric().GetType()
// Check client UID label first
clientUIDLabel, ok := ts.GetMetric().GetLabels()[metrics.MetricLabelKeyClientUID]
if !ok || clientUIDLabel != clientUID {
// Metric does not match target client UID. Skipping
continue
}
wantStatus := metrics.CanonicalString(codes.OK)
if strings.Contains(metricType, metrics.MetricNameFirstRespLatencies) && ts.GetMetric().GetLabels()[metrics.MetricLabelKeyStatus] != wantStatus {
t.Errorf("Incorrect status: got: %v, want: %v", ts.GetMetric().GetLabels()[metrics.MetricLabelKeyStatus], wantStatus)
}
// If we reach here, the metric belongs to our test client instance
foundMetricsForClientUID = append(foundMetricsForClientUID, metricType)
points := ts.GetPoints()
if len(points) == 0 {
continue
}
typedVal := points[0].GetValue()
if typedVal == nil {
continue
}
distVal := typedVal.GetDistributionValue()
if distVal == nil {
continue
}
if distVal.GetCount() != 1 {
continue
}
// Already filtered by clientUID above.
if strings.HasSuffix(metricType, metrics.MetricNameFirstRespLatencies) {
firstRespLatencyValue = distVal.GetMean()
} else if strings.HasSuffix(metricType, metrics.MetricNameOperationLatencies) {
opLatencyValue = distVal.GetMean()
}
}
}
if firstRespLatencyValue == -1 {
t.Fatalf("first_response_latencies metric not found or had count != 1 for client UID '%s'. foundMetricsForClientUID: %+v", clientUID, foundMetricsForClientUID)
}
if opLatencyValue == -1 {
t.Fatalf("operation_latencies metric not found or had count != 1 for client UID '%s'. foundMetricsForClientUID: %+v", clientUID, foundMetricsForClientUID)
}
// Assertions
if firstRespLatencyValue <= 0 {
t.Errorf("firstRespLatencyValue: got %v, want > 0", firstRespLatencyValue)
}
if opLatencyValue <= firstRespLatencyValue {
t.Errorf("opLatencyValue (%v) should be greater than firstRespLatencyValue (%v)", opLatencyValue, firstRespLatencyValue)
}
expectedMinOpLatency := float64(sleepDurationForTest / time.Millisecond)
if opLatencyValue <= expectedMinOpLatency {
t.Errorf("opLatencyValue: got %v, want > %v (sleepDuration)", opLatencyValue, expectedMinOpLatency)
}
// The primary assertion: firstRespLatencyValue should not include the sleepDuration.
// opLatencyValue = firstRespLatencyValue + serverProcessingTimeBetweenFirstAndLastResponse + clientProcessingTimeAfterLastChunk
// serverProcessingTimeBetweenFirstAndLastResponse includes the sleepDurationForTest
// So, opLatencyValue should be roughly firstRespLatencyValue + sleepDurationForTest + other_small_overheads
epsilon := 150.0 // Epsilon in milliseconds
// It can be that firstRespLatencyValue is very small (e.g. <1ms) and opLatencyValue is dominated by sleepDuration + server processing of 2nd chunk.
// Assert that opLatencyValue is greater than or equal to firstRespLatency + sleepDuration, minus epsilon.
// opLatencyValue >= firstRespLatencyValue + float64(sleepDurationForTest/time.Millisecond) - epsilon
// This is equivalent to: firstRespLatencyValue + float64(sleepDurationForTest/time.Millisecond) <= opLatencyValue + epsilon
// This ensures that the first response latency is recorded *before* the artificial delay.
lowerBoundForOpLatency := firstRespLatencyValue + float64(sleepDurationForTest/time.Millisecond)
if opLatencyValue < lowerBoundForOpLatency-epsilon {
t.Errorf("opLatencyValue (%v) is too small. Expected it to be >= firstRespLatencyValue (%v) + sleepDuration (%v ms) - epsilon (%v ms). Difference: %v",
opLatencyValue, firstRespLatencyValue, float64(sleepDurationForTest/time.Millisecond), epsilon, lowerBoundForOpLatency-opLatencyValue)
}
// first response latency should be significantly smaller than the sleep duration.
if firstRespLatencyValue >= float64(sleepDurationForTest/time.Millisecond) {
t.Errorf("firstRespLatencyValue (%v) should be significantly smaller than sleepDuration (%v ms)", firstRespLatencyValue, float64(sleepDurationForTest/time.Millisecond))
}
}
func TestApplicationLatencies(t *testing.T) {
ctx := context.Background()
project := "test-project"
instance := "test-instance"
tableID := "test-table" // Defined for assertion consistency
appProfile := "test-app-profile-app-latency"
clientUID := "test-uid-app-latency" // Unique UID for this test
// appProcessingDelay will be used in the ReadRows callback
const appProcessingDelay = 150 * time.Millisecond
// Reduce sampling period to reduce test run time
origSamplePeriod := metrics.DefaultSamplePeriod
metrics.DefaultSamplePeriod = 100 * time.Millisecond // Shorter for quicker metric export
defer func() {
metrics.DefaultSamplePeriod = origSamplePeriod
}()
// return constant client UID instead of random, so that attributes can be compared
origGenerateClientUID := metrics.GenerateClientUID
metrics.GenerateClientUID = func() (string, error) {
return clientUID, nil
}
defer func() {
metrics.GenerateClientUID = origGenerateClientUID
}()
// Set up a mock error handler to swallow expected shutdown errors
mer := &MockErrorHandler{buffer: new(bytes.Buffer)}
origErrHandler := otel.GetErrorHandler()
otel.SetErrorHandler(mer)
t.Cleanup(func() {
otel.SetErrorHandler(origErrHandler)
})
// Setup mock monitoring server
monitoringServer, err := metricstest.NewServer()
if err != nil {
t.Fatalf("Error setting up metrics test server: %v", err)
}
go monitoringServer.Serve()
defer monitoringServer.Shutdown()
// Override exporter options
origCreateExporterOptions := metrics.CreateExporterOptions
metrics.CreateExporterOptions = func(opts ...option.ClientOption) []option.ClientOption {
return []option.ClientOption{
option.WithEndpoint(monitoringServer.Endpoint),
option.WithoutAuthentication(),
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
}
}
defer func() {
metrics.CreateExporterOptions = origCreateExporterOptions
}()
// Setup fake Bigtable server which returns 2 rows
tbl, cleanup, err := setupFakeServerWithCustomHandler(project, instance, ClientConfig{AppProfile: appProfile}, sendTwoRowsHandler)
defer cleanup()
if err != nil {
t.Fatalf("setupFakeServerWithMock error: got: %v, want: nil", err)
return
}
// Pop out any old requests from other tests
monitoringServer.CreateServiceTimeSeriesRequests()
// Perform read rows operation
var rowsProcessed int
readErr := tbl.ReadRows(ctx, NewRange("a", "z"), func(r Row) bool {
// Simulate application processing time for each row
time.Sleep(appProcessingDelay)
rowsProcessed++
return rowsProcessed < 2 // Process 2 rows
})
if readErr != nil {
t.Fatalf("ReadRows failed: %v", readErr)
}
// Allow time for metrics to be exported
// Wait for at least 3 export cycles to be reasonably sure metrics are flushed.
// Increased sleep duration to see if it's a timing issue.
time.Sleep(metrics.DefaultSamplePeriod * 10)
// Fetch and analyze metrics
requests := monitoringServer.CreateServiceTimeSeriesRequests()
if len(requests) == 0 {
t.Fatalf("No CreateTimeSeriesRequests received from mock monitoring server")
}
foundAppLatencyMetric := false
expectedMetricType := metrics.BuiltInMetricsMeterName + metrics.MetricNameAppBlockingLatencies
expectedTotalAppLatency := float64(rowsProcessed) * float64(appProcessingDelay/time.Millisecond)
epsilon := 50.0 // Epsilon in milliseconds, allow for some overhead
for _, req := range requests {
for _, ts := range req.TimeSeries {
metricType := ts.GetMetric().GetType()
metricLabels := ts.GetMetric().GetLabels()
if clientUIDLabel, ok := metricLabels[metrics.MetricLabelKeyClientUID]; !ok || clientUIDLabel != clientUID {
continue // Not for this client
}
if metricType == expectedMetricType {
foundAppLatencyMetric = true
points := ts.GetPoints()
if len(points) == 0 {
t.Errorf("No points found for metric %s", expectedMetricType)
continue
}
distVal := points[0].GetValue().GetDistributionValue()
if distVal == nil {
t.Errorf("DistributionValue is nil for metric %s", expectedMetricType)
continue
}
if distVal.GetCount() != 1 {
t.Errorf("GetCount(): got %d, want 1 for metric %s", distVal.GetCount(), expectedMetricType)
}
recordedMean := distVal.GetMean()
if recordedMean < expectedTotalAppLatency-epsilon || recordedMean > expectedTotalAppLatency+epsilon {
t.Errorf("App latency mean: got %v, want within %v ± %v", recordedMean, expectedTotalAppLatency, epsilon)
}
// Assert standard labels
if method, ok := metricLabels[metrics.MetricLabelKeyMethod]; !ok || method != "Bigtable.ReadRows" {
t.Errorf("Label %s: got %v, want Bigtable.ReadRows", metrics.MetricLabelKeyMethod, method)
}
// Assert streaming label is not present (as per metricsDetails)
if _, exists := metricLabels[metrics.MetricLabelKeyStreamingOperation]; exists {
t.Errorf("Label %s should not be present for %s", metrics.MetricLabelKeyStreamingOperation, expectedMetricType)
}
resLabels := ts.GetResource().GetLabels()
if tblName, ok := resLabels[metrics.MetricLabelKeyTable]; (ok && tblName != tableID && tblName != "") || !ok {
t.Errorf("Label %s: got %q, want %q for resource %s", metrics.MetricLabelKeyTable, tblName, tableID, ts.GetResource())
}
}
}
}
if !foundAppLatencyMetric {
t.Errorf("Failed to find metric %s for client UID %s", expectedMetricType, clientUID)
}
}
func TestCanonicalString(t *testing.T) {
tests := []struct {
code codes.Code
want string
}{
{codes.OK, "OK"},
{codes.Canceled, "CANCELED"},
{codes.DeadlineExceeded, "DEADLINE_EXCEEDED"},
{codes.Code(100), "CODE(100)"},
}
for _, test := range tests {
got := metrics.CanonicalString(test.code)
if got != test.want {
t.Errorf("canonicalString(%v) = %q, want %q", test.code, got, test.want)
}
}
}
func TestClientBlockingLatency(t *testing.T) {
ctx := context.Background()
project := "test-project"
instance := "test-instance"
appProfile := "test-app-profile-client-blocking"
clientUID := "test-uid-client-blocking"
origSamplePeriod := metrics.DefaultSamplePeriod
metrics.DefaultSamplePeriod = 100 * time.Millisecond
defer func() { metrics.DefaultSamplePeriod = origSamplePeriod }()
origGenerateClientUID := metrics.GenerateClientUID
metrics.GenerateClientUID = func() (string, error) { return clientUID, nil }
defer func() { metrics.GenerateClientUID = origGenerateClientUID }()
mer := &MockErrorHandler{buffer: new(bytes.Buffer)}
origErrHandler := otel.GetErrorHandler()
otel.SetErrorHandler(mer)
t.Cleanup(func() { otel.SetErrorHandler(origErrHandler) })
monitoringServer, err := metricstest.NewServer()
if err != nil {
t.Fatalf("Error setting up metrics test server: %v", err)
}
go monitoringServer.Serve()
defer monitoringServer.Shutdown()
origCreateExporterOptions := metrics.CreateExporterOptions
metrics.CreateExporterOptions = func(opts ...option.ClientOption) []option.ClientOption {
return []option.ClientOption{
option.WithEndpoint(monitoringServer.Endpoint),
option.WithoutAuthentication(),
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
}
}
defer func() { metrics.CreateExporterOptions = origCreateExporterOptions }()
tbl, cleanup, err := setupFakeServerWithCustomHandler(project, instance, ClientConfig{AppProfile: appProfile}, sendTwoRowsHandler)
defer cleanup()
if err != nil {
t.Fatalf("setupFakeServerWithCustomHandler error: got: %v, want: nil", err)
}
monitoringServer.CreateServiceTimeSeriesRequests()
// Perform read rows operation
readErr := tbl.ReadRows(ctx, NewRange("a", "z"), func(r Row) bool { return true })
if readErr != nil {
t.Fatalf("ReadRows failed: %v", readErr)
}
// Allow time for metrics to be exported
time.Sleep(metrics.DefaultSamplePeriod * 5)
requests := monitoringServer.CreateServiceTimeSeriesRequests()
foundMetric := false
for _, req := range requests {
for _, ts := range req.TimeSeries {
metricType := ts.GetMetric().GetType()
if !strings.HasSuffix(metricType, metrics.MetricNameClientBlockingLatencies) {
continue
}
clientUIDLabel, ok := ts.GetMetric().GetLabels()[metrics.MetricLabelKeyClientUID]
if !ok || clientUIDLabel != clientUID {
continue
}
foundMetric = true
points := ts.GetPoints()
if len(points) == 0 {
continue
}
distVal := points[0].GetValue().GetDistributionValue()
if distVal == nil {
continue
}
if distVal.GetCount() != 1 {
t.Errorf("GetCount(): got %d, want 1", distVal.GetCount())
}
// Client blocking latency should be measured and strictly >= 0
if distVal.GetMean() < 0 {
t.Errorf("Client blocking latency should be >= 0, got: %v", distVal.GetMean())
}
}
}
if !foundMetric {
t.Fatalf("Failed to find metric %s for client UID %s", metrics.MetricNameClientBlockingLatencies, clientUID)
}
}