Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
1d648f0
Add the logging interceptor
danieljbruce Jul 2, 2025
ff27288
Unary call interceptor experiment
danieljbruce Jul 3, 2025
84a006a
Add the method that gets the interceptor
danieljbruce Jul 3, 2025
4aa33df
Fix types in the interceptor
danieljbruce Jul 3, 2025
39c26b0
feat: add isolated system test for ReadModifyWriteRow client metrics
google-labs-jules[bot] Jul 3, 2025
edf122e
feat: test ReadModifyWriteRow metrics with mock server and interceptors
google-labs-jules[bot] Jul 3, 2025
945c4c6
refactor: correctly manage collector lifecycle in RMW metrics test
google-labs-jules[bot] Jul 3, 2025
6b377b6
chore: add extensive debug logging to RMW metrics test
google-labs-jules[bot] Jul 3, 2025
80d1b2d
More updates to the test code. Getting it working
danieljbruce Jul 3, 2025
b2e13ee
Fix the readModifyWriteRow service
danieljbruce Jul 4, 2025
1df9aa3
Mock service is being reached
danieljbruce Jul 4, 2025
8b33974
Delete service class
danieljbruce Jul 4, 2025
272ee4f
Switch only
danieljbruce Jul 4, 2025
4905815
successful metadata retrieval
danieljbruce Jul 4, 2025
15be99e
Fixed bug to fetch metrics
danieljbruce Jul 4, 2025
42b8639
Passing test
danieljbruce Jul 4, 2025
5550687
Eliminate unncessary code
danieljbruce Jul 4, 2025
3d740c4
Eliminate lots of unecessary code
danieljbruce Jul 4, 2025
873c15e
Move createMetricsInterceptorProvider
danieljbruce Jul 4, 2025
8373ca0
Add some comments
danieljbruce Jul 4, 2025
d02d0c1
Updated comment
danieljbruce Jul 4, 2025
bb2cdcd
Don’t rely on another method to make this work
danieljbruce Jul 4, 2025
a054d37
Add comments to themethd
danieljbruce Jul 4, 2025
59a3591
Delete unused code
danieljbruce Jul 4, 2025
96ab36e
Merge branch 'feat/read-modify-write-row-metrics-isolated-test' of ht…
danieljbruce Jul 4, 2025
e2e0f97
Pull out function that attaches interceptors
danieljbruce Jul 4, 2025
2f513b0
More documentation for the test helpers
danieljbruce Jul 4, 2025
eabbc0a
Rename
danieljbruce Jul 4, 2025
8ae93b0
After hook for instance clean-up
danieljbruce Jul 4, 2025
5dd36f4
Remove TODO
danieljbruce Jul 4, 2025
fa2046a
Undo proto changes
danieljbruce Jul 4, 2025
8282131
TODO done
danieljbruce Jul 4, 2025
e56d82a
Eliminate unused interceptor code
danieljbruce Jul 4, 2025
8682791
Revert client library behavior to main
danieljbruce Jul 4, 2025
e107d61
Delete unused test
danieljbruce Jul 4, 2025
dae67f0
Add header
danieljbruce Jul 4, 2025
cab25c5
Remove only
danieljbruce Jul 4, 2025
45fc2f2
Refactor some variables
danieljbruce Jul 4, 2025
ea8b058
Merge branch 'main' into feat/read-modify-write-row-metrics-isolated-…
danieljbruce Jul 8, 2025
5d63c11
🦉 Updates from OwlBot post-processor
gcf-owl-bot[bot] Jul 8, 2025
859b5c5
Protos should match main
danieljbruce Jul 14, 2025
6a33923
Merge branch 'feat/read-modify-write-row-metrics-isolated-test' of ht…
danieljbruce Jul 14, 2025
5a92b25
Revert MC changes to simplify types
danieljbruce Jul 14, 2025
a23b5d7
Remove import statement
danieljbruce Jul 14, 2025
987d8a3
Merge branch 'main' into feat/read-modify-write-row-metrics-isolated-…
danieljbruce Jul 14, 2025
a3ff4f0
Merge branch 'main' into feat/read-modify-write-row-metrics-isolated-…
danieljbruce Jul 18, 2025
d4a68b2
onResponse call removed
danieljbruce Jul 18, 2025
7662205
Remove the saveReceived message for local instance
danieljbruce Jul 22, 2025
a1012fc
Merge branch 'main' into feat/read-modify-write-row-metrics-isolated-…
danieljbruce Jul 22, 2025
cf08449
Removed unnecessary code
danieljbruce Jul 23, 2025
2befabc
Always add the client side metric interceptor
danieljbruce Jul 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2025 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
//
// https://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.

import {CallOptions} from 'google-gax';
import {OperationMetricsCollector} from './client-side-metrics/operation-metrics-collector';

// Mock Server Implementation
import * as grpcJs from '@grpc/grpc-js';
import {status as GrpcStatus} from '@grpc/grpc-js';

export type ServerStatus = {
metadata: {internalRepr: Map<string, Uint8Array[]>; options: {}};
code: number;
details: string;
};

// Helper to create interceptor provider for OperationMetricsCollector
function createMetricsInterceptorProvider(
collector: OperationMetricsCollector,
) {
return (options: grpcJs.InterceptorOptions, nextCall: grpcJs.NextCall) => {
// savedReceiveMetadata and savedReceiveStatus are not strictly needed here anymore for the interceptor's own state
// OperationStart and AttemptStart will be called by the calling code (`fakeReadModifyWriteRow`)
return new grpcJs.InterceptingCall(nextCall(options), {
start: (metadata, listener, next) => {
// AttemptStart is called by the orchestrating code
const newListener: grpcJs.Listener = {
onReceiveMetadata: (metadata, nextMd) => {
collector.onMetadataReceived(
metadata as unknown as {
internalRepr: Map<string, string[]>;
options: {};
},
);
nextMd(metadata);
},
onReceiveStatus: (status, nextStat) => {
collector.onStatusMetadataReceived(
status as unknown as ServerStatus,
);
nextStat(status);
},
};
next(metadata, newListener);
},
});
};
}

export function withInterceptors(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you show me how you expect to use this? Does something like this need to be called at the start of each request?

Copy link
Contributor Author

@danieljbruce danieljbruce Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a method call we write something like this:

        bigtable.request(
          {
            client: 'BigtableClient',
            method: 'readModifyWriteRow',
            reqOpts: {
              tableName: table.name,
              rowKey: Buffer.from(ROW_KEY),
              rules: [
                {
                  familyName: COLUMN_FAMILY,
                  columnQualifier: Buffer.from(COLUMN),
                  appendValue: Buffer.from('-wood'),
                },
              ],
              appProfileId: undefined,
            },
            gaxOpts: withInterceptors({}, metricsCollector),
          },
          (err: ServiceError | null, resp?: any) => {
            if (err) {
              reject(err);
            } else {
              resolve(resp);
            }
          },
        );

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the tests below. The interceptors are added onto gax options.

gaxOptions: CallOptions,
metricsCollector?: OperationMetricsCollector,
) {
if (metricsCollector) {
const interceptor = createMetricsInterceptorProvider(metricsCollector);
if (!gaxOptions.otherArgs) {
gaxOptions.otherArgs = {};
}
if (!gaxOptions.otherArgs.options) {
gaxOptions.otherArgs.options = {};
}
if (!gaxOptions.otherArgs.options.interceptors) {
gaxOptions.otherArgs.options.interceptors = [interceptor];
} else {
if (Array.isArray(gaxOptions.otherArgs.options.interceptors)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this would scan better as an else if

// We check that interceptors is an array so that the code has no
// chance of throwing an error.
// Then, if the interceptors is an array, make sure it also includes the
// client side metrics interceptor.
gaxOptions.otherArgs.options.interceptors.push(interceptor);
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't you need to add the interceptor, even if other interceptors were added?

Copy link
Contributor Author

@danieljbruce danieljbruce Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That really depends on how we want to design the system. Here are the pros and cons of adding the interceptor.

Pros:

  • Existing users that are already using interceptors, likely a small minority of our users, will automatically be using the new interceptor now so they don't have to add the CSM interceptor to their interceptors

Cons:

  • It means the user doesn't have full control of the interceptors which reduces flexibility

Yeah, I guess the pros outweigh the cons so we can add this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I'm not sure I like this change anymore. If the interceptors are not an array (although they should be) then trying to push another interceptor onto interceptors will throw an error. I don't want users to experience that just in case they are using interceptors incorrectly. As you can see in the screenshot, the compiler doesn't guarantee any particular structure for the interceptors property.

image

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make sure I'm understanding this right: with the current code, if the user passes in their own interceptors, they would effectively be disabling csm, right?

What else could they be passing other than an array? There must be some docstring somewhere telling us what we need to support

Copy link
Contributor Author

@danieljbruce danieljbruce Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the user passes in their own interceptors, they would effectively be disabling csm, right?

Yes, unless they explicitly passed in the CSM interceptor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this says "Additional arguments to be passed to the API calls." That's not helpful enough so I'll keep looking.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the code examples pass interceptors in as an array. If you feel strongly about adding it to the existing interceptors then we can make that change, but I just don't like how the compiler doesn't guarantee that it is an array.

Copy link

@daniel-sanche daniel-sanche Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check the type of gaxOptions.otherArgs.options.interceptors, and only append if it is a list? And ignore unexpected input types?

I really don't think we should silently disable CSM if the user passes in an interceptor. That would present as a bug, and it could be difficult to track down

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I made this change.

}
return gaxOptions;
}
294 changes: 294 additions & 0 deletions system-test/read-modify-write-row-interceptors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
// Copyright 2025 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
//
// https://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.

import {describe, it, before, after} from 'mocha';
import {Bigtable} from '../src';
import {ServiceError} from 'google-gax';
import {ClientSideMetricsConfigManager} from '../src/client-side-metrics/metrics-config-manager';
import {TestMetricsHandler} from '../test-common/test-metrics-handler';
import {
OnAttemptCompleteData,
OnOperationCompleteData,
} from '../src/client-side-metrics/metrics-handler';
import {OperationMetricsCollector} from '../src/client-side-metrics/operation-metrics-collector';
import {
MethodName,
StreamingState,
} from '../src/client-side-metrics/client-side-metrics-attributes';
import * as assert from 'assert';
import {status as GrpcStatus} from '@grpc/grpc-js';
import {withInterceptors} from '../src/interceptor';

const INSTANCE_ID = 'isolated-rmw-instance';
const TABLE_ID = 'isolated-rmw-table';
const ZONE = 'us-central1-a';
const CLUSTER = 'fake-cluster';
const COLUMN_FAMILY = 'traits';
const COLUMN_FAMILIES = [COLUMN_FAMILY];
const ROW_KEY = 'gwashington';
const COLUMN = 'teeth';

/**
* Creates a Bigtable instance if it does not already exist.
*
* @param bigtable - The Bigtable client.
* @param instanceId - The ID of the instance to create.
* @param clusterId - The ID of the initial cluster in the instance.
* @param locationId - The location (region) for the initial cluster.
* @returns The created instance object if successful, otherwise logs a message and returns the existing instance.
*/
async function createInstance(
bigtable: Bigtable,
instanceId: string,
clusterId: string,
locationId: string,
) {
const instance = bigtable.instance(instanceId);

const [exists] = await instance.exists();
if (exists) {
console.log(`Instance ${instanceId} already exists.`);
return instance;
}

const [i, operation] = await instance.create({
clusters: [
{
id: clusterId,
location: locationId,
nodes: 3,
},
],
labels: {
time_created: Date.now(),
},
});
await operation.promise();
console.log(`Created instance ${instanceId}`);
return i;
}

/**
* Creates a Bigtable table if it does not already exist.
*
* @param bigtable - The Bigtable client.
* @param instanceId - The ID of the instance containing the table.
* @param tableId - The ID of the table to create.
* @param families - An array of column family names to create in the table.
* @returns A promise that resolves with the created Table object.
*/
async function createTable(
bigtable: Bigtable,
instanceId: string,
tableId: string,
families: string[],
) {
const instance = bigtable.instance(instanceId);
const table = instance.table(tableId);

const [exists] = await table.exists();
if (exists) {
console.log(`Table ${tableId} already exists.`);
return table;
}

const [t] = await table.create({
families: families,
});
const row = table.row(ROW_KEY);
await row.save({
[COLUMN_FAMILY]: {
[COLUMN]: 'shiny',
},
});
console.log(`Created table ${tableId}`);
return t;
}

/**
* Creates and returns a TestMetricsHandler instance for testing purposes.
*
* @returns A TestMetricsHandler instance with the projectId set to 'test-project-id'.
*/
function getTestMetricsHandler() {
const testMetricsHandler = new TestMetricsHandler();
testMetricsHandler.projectId = 'test-project-id';
return testMetricsHandler;
}

/**
* Asynchronously retrieves the project ID associated with the Bigtable client.
*
* @param bigtable - The Bigtable client instance.
* @returns A promise that resolves with the project ID as a string.
* @throws An error if the project ID cannot be retrieved.
*/
async function getProjectIdFromClient(bigtable: Bigtable): Promise<string> {
return new Promise((resolve, reject) => {
bigtable.getProjectId_((err, projectId) => {
if (err) {
reject(err);
} else {
resolve(projectId!);
}
});
});
}

describe('Bigtable/ReadModifyWriteRowInterceptorMetrics', () => {
let bigtable: Bigtable;
let testMetricsHandler: TestMetricsHandler;

before(async () => {
bigtable = new Bigtable();
await getProjectIdFromClient(bigtable);
await createInstance(bigtable, INSTANCE_ID, CLUSTER, ZONE);
await createTable(bigtable, INSTANCE_ID, TABLE_ID, COLUMN_FAMILIES);
testMetricsHandler = getTestMetricsHandler();
bigtable._metricsConfigManager = new ClientSideMetricsConfigManager([
testMetricsHandler,
]);
});

after(async () => {
const instance = bigtable.instance(INSTANCE_ID);
await instance.delete();
});

it('should record and export correct metrics for ReadModifyWriteRow via interceptors', async () => {
const instance = bigtable.instance(INSTANCE_ID);

const table = instance.table(TABLE_ID);

/*
fakeReadModifyWriteRowMethod is just a fake method on a table that makes a
call to the readWriteModifyRow grpc endpoint. It demonstrates what a method
might look like when trying to make a unary call while extracting
information from the headers and trailers that the server returns so that
the extracted information can be recorded in client side metrics.
*/
(table as any).fakeReadModifyWriteRowMethod = async () => {
// 1. Create a metrics collector.
const metricsCollector = new OperationMetricsCollector(
table,
MethodName.READ_MODIFY_WRITE_ROW,
StreamingState.UNARY,
(table as any).bigtable._metricsConfigManager!.metricsHandlers,
);
// 2. Tell the metrics collector an attempt has been started.
metricsCollector.onOperationStart();
metricsCollector.onAttemptStart();
// 3. Make a unary call with gax options that include interceptors. The
// interceptors are built from a method that hooks them up to the
// metrics collector
const responseArray = await new Promise((resolve, reject) => {
bigtable.request(
{
client: 'BigtableClient',
method: 'readModifyWriteRow',
reqOpts: {
tableName: table.name,
rowKey: Buffer.from(ROW_KEY),
rules: [
{
familyName: COLUMN_FAMILY,
columnQualifier: Buffer.from(COLUMN),
appendValue: Buffer.from('-wood'),
},
],
appProfileId: undefined,
},
gaxOpts: withInterceptors({}, metricsCollector),
},
(err: ServiceError | null, resp?: any) => {
if (err) {
reject(err);
} else {
resolve(resp);
}
},
);
});
// 4. Tell the metrics collector the attempt is over
metricsCollector.onAttemptComplete(GrpcStatus.OK);
metricsCollector.onOperationComplete(GrpcStatus.OK);
// 5. Return results of method call to the user
return responseArray;
};

await (table as any).fakeReadModifyWriteRowMethod();

assert.strictEqual(testMetricsHandler.requestsHandled.length, 2);

const attemptCompleteData = testMetricsHandler.requestsHandled.find(
m => (m as {attemptLatency?: number}).attemptLatency !== undefined,
) as OnAttemptCompleteData | undefined;
const operationCompleteData = testMetricsHandler.requestsHandled.find(
m => (m as {operationLatency?: number}).operationLatency !== undefined,
) as OnOperationCompleteData | undefined;

assert.ok(attemptCompleteData, 'OnAttemptCompleteData should be present');
assert.ok(
operationCompleteData,
'OnOperationCompleteData should be present',
);
if (!attemptCompleteData || !operationCompleteData) {
throw new Error('Metrics data is missing'); // Should be caught by asserts above
}
assert.strictEqual(
attemptCompleteData.metricsCollectorData.method,
MethodName.READ_MODIFY_WRITE_ROW,
);
assert.strictEqual(attemptCompleteData.status, '0');
assert.strictEqual(
attemptCompleteData.metricsCollectorData.table,
TABLE_ID,
);
assert.strictEqual(
attemptCompleteData.metricsCollectorData.instanceId,
INSTANCE_ID,
);
assert.ok(attemptCompleteData.attemptLatency >= 0);
assert(attemptCompleteData.serverLatency);
assert.ok(attemptCompleteData.serverLatency >= 0);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we prove we are getting the server latency from the metadata headers.

assert.strictEqual(attemptCompleteData.metricsCollectorData.zone, ZONE);
assert.strictEqual(
attemptCompleteData.metricsCollectorData.cluster,
CLUSTER,
);
assert.strictEqual(attemptCompleteData.streaming, StreamingState.UNARY);

assert.strictEqual(
operationCompleteData.metricsCollectorData.method,
MethodName.READ_MODIFY_WRITE_ROW,
);
assert.strictEqual(operationCompleteData.status, '0');
assert.strictEqual(
operationCompleteData.metricsCollectorData.table,
TABLE_ID,
);
assert.strictEqual(
operationCompleteData.metricsCollectorData.instanceId,
INSTANCE_ID,
);
assert.ok(operationCompleteData.operationLatency >= 0);
assert.strictEqual(operationCompleteData.retryCount, 0);
assert.strictEqual(operationCompleteData.metricsCollectorData.zone, ZONE);
assert.strictEqual(
operationCompleteData.metricsCollectorData.cluster,
CLUSTER,
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we prove we are getting the Zone/Cluster from the trailer.

assert.strictEqual(operationCompleteData.streaming, StreamingState.UNARY);
});
});
Loading