From 8efd29e4752d46ab4f1ba5b59064913e192d472a Mon Sep 17 00:00:00 2001 From: "gcf-owl-bot[bot]" <78513119+gcf-owl-bot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:45:43 +0530 Subject: [PATCH 1/7] chore: update python post processor base image (#2416) Source-Link: https://github.com/googleapis/synthtool/commit/fe7743817ba2dced52578706639ba194966fba4e Post-Processor: gcr.io/cloud-devrel-public-resources/owlbot-nodejs:latest@sha256:84adf917cad8f48c61227febebae7af619882d7c8863d6ab6290a77d45a372cf Co-authored-by: Owl Bot Co-authored-by: alkatrivedi <58396306+alkatrivedi@users.noreply.github.com> --- .github/.OwlBot.lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index 1b31dcf66..50bc3b4e0 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-nodejs:latest - digest: sha256:d7121436478154661c563c87f931ca78030903b813239c577f91021cda86cec3 -# created: 2025-08-28T17:36:59.211564853Z + digest: sha256:84adf917cad8f48c61227febebae7af619882d7c8863d6ab6290a77d45a372cf +# created: 2025-09-10T20:42:34.536728816Z From 110923ea1dc6f6c891e0f70406b3839224a25b9e Mon Sep 17 00:00:00 2001 From: surbhigarg92 Date: Mon, 22 Sep 2025 20:53:17 +0530 Subject: [PATCH 2/7] fix: Metrics Export Error log (#2425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Metrics Export Error log * fix: Metrics Export Error log * fix: Metrics Export Error log * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot --- src/metrics/spanner-metrics-exporter.ts | 24 ++++++++++++++---------- test/metrics/spanner-metrics-exporter.ts | 5 +---- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/metrics/spanner-metrics-exporter.ts b/src/metrics/spanner-metrics-exporter.ts index 8846a5782..1974e08b5 100644 --- a/src/metrics/spanner-metrics-exporter.ts +++ b/src/metrics/spanner-metrics-exporter.ts @@ -31,6 +31,7 @@ export class CloudMonitoringMetricsExporter implements PushMetricExporter { private _projectId: string | void | Promise; private _lastExported: Date = new Date(0); private readonly _client: MetricServiceClient; + private _metricsExportFailureLogged = false; constructor({auth}: ExporterOptions) { this._client = new MetricServiceClient({auth: auth}); @@ -65,7 +66,6 @@ export class CloudMonitoringMetricsExporter implements PushMetricExporter { this._lastExported = now; this._exportAsync(metrics).then(resultCallback, err => { - console.error(err.message); resultCallback({code: ExportResultCode.FAILED, error: err}); }); } @@ -104,18 +104,22 @@ export class CloudMonitoringMetricsExporter implements PushMetricExporter { async batchedTimeSeries => this._sendTimeSeries(batchedTimeSeries), ), ).catch(e => { - const error = e as {code: number}; - if (error.code === status.PERMISSION_DENIED) { - console.warn( - `Need monitoring metric writer permission on project ${this._projectId}. Follow https://cloud.google.com/spanner/docs/view-manage-client-side-metrics#access-client-side-metrics to set up permissions`, - ); + if (!this._metricsExportFailureLogged) { + const error = e as {code: number}; + let msg = 'Send TimeSeries failed:'; + if (error.code === status.PERMISSION_DENIED) { + msg += ` Need monitoring metric writer permission on project ${this._projectId}. Follow https://cloud.google.com/spanner/docs/view-manage-client-side-metrics#access-client-side-metrics to set up permissions`; + } + console.warn(msg); + this._metricsExportFailureLogged = true; } - const err = asError(e); - err.message = `Send TimeSeries failed: ${err.message}`; - failure = {sendFailed: true, error: err}; - console.error(`ERROR: ${err.message}`); + failure = {sendFailed: true, error: asError(e)}; }); + if (!failure.sendFailed && this._metricsExportFailureLogged) { + this._metricsExportFailureLogged = false; + } + return failure.sendFailed ? { code: ExportResultCode.FAILED, diff --git a/test/metrics/spanner-metrics-exporter.ts b/test/metrics/spanner-metrics-exporter.ts index 8f056669c..ea99c72bc 100644 --- a/test/metrics/spanner-metrics-exporter.ts +++ b/test/metrics/spanner-metrics-exporter.ts @@ -226,10 +226,7 @@ describe('Export', () => { const callbackResult = resultCallbackSpy.getCall(0).args[0]; assert.strictEqual(callbackResult.code, ExportResultCode.FAILED); - assert.strictEqual( - callbackResult.error.message, - 'Send TimeSeries failed: Network error', - ); + assert.strictEqual(callbackResult.error.message, 'Network error'); assert(sendTimeSeriesStub.calledOnce); }); From c1d71388c425da820558e0e81035a17239e4aeeb Mon Sep 17 00:00:00 2001 From: alkatrivedi <58396306+alkatrivedi@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:06:23 +0000 Subject: [PATCH 3/7] chore: sample for snapshot isolation (#2265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: sample for snapshot isolation * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * refactor: sample comments * refactor sample * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * refactor: tests * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * change naming * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * fix: samples-test --------- Co-authored-by: Owl Bot --- README.md | 1 + samples/README.md | 18 ++++++ samples/repeatable-reads.js | 98 +++++++++++++++++++++++++++++ samples/system-test/spanner.test.js | 18 ++++++ 4 files changed, 135 insertions(+) create mode 100644 samples/repeatable-reads.js diff --git a/README.md b/README.md index 6621b6cd9..98724d036 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ Samples are in the [`samples/`](https://github.com/googleapis/nodejs-spanner/tre | Queryoptions | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/queryoptions.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/queryoptions.js,samples/README.md) | | Quickstart | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/quickstart.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/quickstart.js,samples/README.md) | | Read data with database role | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/read-data-with-database-role.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/read-data-with-database-role.js,samples/README.md) | +| Performs a read-write transaction with isolation level option | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/repeatable-reads.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/repeatable-reads.js,samples/README.md) | | Sets a request tag for a single query | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/request-tag.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/request-tag.js,samples/README.md) | | Run Batch update with RPC priority | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/rpc-priority-batch-dml.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/rpc-priority-batch-dml.js,samples/README.md) | | Run partitioned update with RPC priority | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/rpc-priority-partitioned-dml.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/rpc-priority-partitioned-dml.js,samples/README.md) | diff --git a/samples/README.md b/samples/README.md index b7670c8c7..be14882d0 100644 --- a/samples/README.md +++ b/samples/README.md @@ -116,6 +116,7 @@ and automatic, synchronous replication for high availability. * [Queryoptions](#queryoptions) * [Quickstart](#quickstart) * [Read data with database role](#read-data-with-database-role) + * [Performs a read-write transaction with isolation level option](#performs-a-read-write-transaction-with-isolation-level-option) * [Sets a request tag for a single query](#sets-a-request-tag-for-a-single-query) * [Run Batch update with RPC priority](#run-batch-update-with-rpc-priority) * [Run partitioned update with RPC priority](#run-partitioned-update-with-rpc-priority) @@ -1888,6 +1889,23 @@ __Usage:__ +### Performs a read-write transaction with isolation level option + +View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/repeatable-reads.js). + +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/repeatable-reads.js,samples/README.md) + +__Usage:__ + + +`node repeatable-reads.js ` + + +----- + + + + ### Sets a request tag for a single query View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/request-tag.js). diff --git a/samples/repeatable-reads.js b/samples/repeatable-reads.js new file mode 100644 index 000000000..2212fe1a8 --- /dev/null +++ b/samples/repeatable-reads.js @@ -0,0 +1,98 @@ +// 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 +// +// 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. + +// sample-metadata: +// title: Performs a read-write transaction with isolation level option +// usage: node repeatable-reads.js + +'use strict'; + +function main( + instanceId = 'my-instance', + databaseId = 'my-database', + projectId = 'my-project-id', +) { + // [START spanner_isolation_level] + // Imports the Google Cloud Spanner client library + const {Spanner, protos} = require('@google-cloud/spanner'); + // The isolation level specified at the client-level will be applied + // to all RW transactions. + const isolationOptionsForClient = { + defaultTransactionOptions: { + isolationLevel: + protos.google.spanner.v1.TransactionOptions.IsolationLevel.SERIALIZABLE, + }, + }; + + // Instantiates a client with defaultTransactionOptions + const spanner = new Spanner({ + projectId: projectId, + defaultTransactionOptions: isolationOptionsForClient, + }); + + function runTransactionWithIsolationLevel() { + // Gets a reference to a Cloud Spanner instance and database + const instance = spanner.instance(instanceId); + const database = instance.database(databaseId); + // The isolation level specified at the request level takes precedence over the isolation level configured at the client level. + const isolationOptionsForTransaction = { + isolationLevel: + protos.google.spanner.v1.TransactionOptions.IsolationLevel + .REPEATABLE_READ, + }; + + database.runTransaction( + isolationOptionsForTransaction, + async (err, transaction) => { + if (err) { + console.error(err); + return; + } + try { + const query = + 'SELECT AlbumTitle FROM Albums WHERE SingerId = 1 AND AlbumId = 1'; + const results = await transaction.run(query); + // Gets first album's title + const rows = results[0].map(row => row.toJSON()); + const albumTitle = rows[0].AlbumTitle; + console.log(`previous album title ${albumTitle}`); + + const update = + "UPDATE Albums SET AlbumTitle = 'New Album Title' WHERE SingerId = 1 AND AlbumId = 1"; + const [rowCount] = await transaction.runUpdate(update); + console.log( + `Successfully updated ${rowCount} record in Albums table.`, + ); + await transaction.commit(); + console.log( + 'Successfully executed read-write transaction with isolationLevel option.', + ); + } catch (err) { + console.error('ERROR:', err); + } finally { + transaction.end(); + // Close the database when finished. + await database.close(); + } + }, + ); + } + runTransactionWithIsolationLevel(); + // [END spanner_isolation_level] +} +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/samples/system-test/spanner.test.js b/samples/system-test/spanner.test.js index a7f93857f..9aa57361b 100644 --- a/samples/system-test/spanner.test.js +++ b/samples/system-test/spanner.test.js @@ -1287,6 +1287,24 @@ describe('Autogenerated Admin Clients', () => { assert.match(output, /VenueId: 19, Details: {"open":true,"rating":9}/); }); + // isolation_level_option + it('should run read-write transaction with isolation level option set', () => { + const output = execSync( + `node repeatable-reads.js ${INSTANCE_ID} ${DATABASE_ID} ${PROJECT_ID}`, + ); + assert.match(output, new RegExp('previous album title Total Junk')); + assert.match( + output, + new RegExp('Successfully updated 1 record in Albums table.'), + ); + assert.match( + output, + new RegExp( + 'Successfully executed read-write transaction with isolationLevel option.', + ), + ); + }); + // add_and_drop_new_database_role it('should add and drop new database roles', async () => { const output = execSync( From 89865ae7a717be71c8f3f3a161e8663629d062b6 Mon Sep 17 00:00:00 2001 From: "gcf-owl-bot[bot]" <78513119+gcf-owl-bot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:50:45 +0530 Subject: [PATCH 4/7] chore: update generator logic for nodejs_gapic_combined_pkg rule, and update templates (#2423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update generator logic for nodejs_gapic_combined_pkg rule, and update templates PiperOrigin-RevId: 808677883 Source-Link: https://github.com/googleapis/googleapis/commit/a32846d78b106d72f51e5e2186bc3be9dbf45c7d Source-Link: https://github.com/googleapis/googleapis-gen/commit/593da2652f2c43b769dfeb19161a3be955f97674 Copy-Tag: eyJwIjoiLmdpdGh1Yi8uT3dsQm90LnlhbWwiLCJoIjoiNTkzZGEyNjUyZjJjNDNiNzY5ZGZlYjE5MTYxYTNiZTk1NWY5NzY3NCJ9 * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot Co-authored-by: alkatrivedi <58396306+alkatrivedi@users.noreply.github.com> --- .OwlBot.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .OwlBot.yaml diff --git a/.OwlBot.yaml b/.OwlBot.yaml new file mode 100644 index 000000000..b80260f10 --- /dev/null +++ b/.OwlBot.yaml @@ -0,0 +1,19 @@ +# 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 +# +# 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. + +deep-copy-regex: + - source: /google/spanner/executor/google-spanner-executor-nodejs + dest: /owl-bot-staging/google-spanner-executor + +api-name: executor \ No newline at end of file From 0d633126a87c1274abfd59550cb94052a819fcaa Mon Sep 17 00:00:00 2001 From: surbhigarg92 Date: Tue, 7 Oct 2025 10:33:58 +0530 Subject: [PATCH 5/7] fix: Correctly determine project ID for metrics export (#2427) * fix: Metrics Export with projectId from Credentials * test cases * fixed the metricstracerfactory instance, it is per projectid now * review comments --- src/index.ts | 51 ++++++++++++++++++------ src/metrics/interceptor.ts | 10 ++++- src/metrics/metrics-tracer-factory.ts | 8 ++-- src/metrics/metrics-tracer.ts | 5 ++- src/metrics/spanner-metrics-exporter.ts | 26 ++++-------- src/metrics/transform.ts | 17 +++++--- test/index.ts | 32 ++++++++++----- test/metrics/interceptor.ts | 4 ++ test/metrics/metrics-tracer-factory.ts | 8 ++-- test/metrics/metrics-tracer.ts | 3 ++ test/metrics/metrics.ts | 5 ++- test/metrics/spanner-metrics-exporter.ts | 14 +++---- test/metrics/transform.ts | 16 +++++--- 13 files changed, 124 insertions(+), 75 deletions(-) diff --git a/src/index.ts b/src/index.ts index e41d37465..d50b08925 100644 --- a/src/index.ts +++ b/src/index.ts @@ -314,6 +314,7 @@ class Spanner extends GrpcService { instances_: Map; instanceConfigs_: Map; projectIdReplaced_: boolean; + projectId_?: string; projectFormattedName_: string; commonHeaders_: {[k: string]: string}; routeToLeaderEnabled = true; @@ -500,6 +501,7 @@ class Spanner extends GrpcService { ensureInitialContextManagerSet(); this._nthClientId = nextSpannerClientId(); this._universeDomain = universeEndpoint; + this.projectId_ = options.projectId; this.configureMetrics_(options.disableBuiltInMetrics); } @@ -1620,19 +1622,44 @@ class Spanner extends GrpcService { * Setup the OpenTelemetry metrics capturing for service metrics to Google Cloud Monitoring. */ configureMetrics_(disableBuiltInMetrics?: boolean) { + // Only enable metrics if not explicitly disabled and we are not using + // insecure credentials. const metricsEnabled = process.env.SPANNER_DISABLE_BUILTIN_METRICS !== 'true' && !disableBuiltInMetrics && !this._isInSecureCredentials; - MetricsTracerFactory.enabled = metricsEnabled; if (metricsEnabled) { - const factory = MetricsTracerFactory.getInstance(this.projectId); - const periodicReader = new PeriodicExportingMetricReader({ - exporter: new CloudMonitoringMetricsExporter({auth: this.auth}), - exportIntervalMillis: 60000, - }); - // Retrieve the MeterProvider to trigger construction - factory!.getMeterProvider([periodicReader]); + try { + this.auth.getProjectId((err, projectId) => { + if (err || !projectId) { + console.error( + 'Unable to get Project Id for client side metrics, will skip exporting client' + + ' side metrics' + + err, + ); + return; + } + + MetricsTracerFactory.enabled = metricsEnabled; + this.projectId_ = projectId; + const factory = MetricsTracerFactory.getInstance(projectId); + const periodicReader = new PeriodicExportingMetricReader({ + exporter: new CloudMonitoringMetricsExporter( + {auth: this.auth}, + projectId, + ), + exportIntervalMillis: 60000, + }); + // Retrieve the MeterProvider to trigger construction + factory!.getMeterProvider([periodicReader]); + }); + } catch (err) { + console.error( + 'Unable to configure client side metrics, will skip exporting client' + + ' side metrics' + + err, + ); + } } } @@ -1782,9 +1809,9 @@ class Spanner extends GrpcService { // eslint-disable-next-line @typescript-eslint/no-explicit-any request(config: any, callback?: any): any { let metricsTracer: MetricsTracer | null = null; - if (config.client === 'SpannerClient') { + if (config.client === 'SpannerClient' && this.projectId_) { metricsTracer = - MetricsTracerFactory?.getInstance()?.createMetricsTracer( + MetricsTracerFactory?.getInstance(this.projectId_)?.createMetricsTracer( config.method, config.reqOpts.session ?? config.reqOpts.database, config.headers['x-goog-spanner-request-id'], @@ -1846,9 +1873,9 @@ class Spanner extends GrpcService { // eslint-disable-next-line @typescript-eslint/no-explicit-any requestStream(config): any { let metricsTracer: MetricsTracer | null = null; - if (config.client === 'SpannerClient') { + if (config.client === 'SpannerClient' && this.projectId_) { metricsTracer = - MetricsTracerFactory?.getInstance()?.createMetricsTracer( + MetricsTracerFactory?.getInstance(this.projectId_)?.createMetricsTracer( config.method, config.reqOpts.session ?? config.reqOpts.database, config.headers['x-goog-spanner-request-id'], diff --git a/src/metrics/interceptor.ts b/src/metrics/interceptor.ts index cb4d0a728..9e1851125 100644 --- a/src/metrics/interceptor.ts +++ b/src/metrics/interceptor.ts @@ -30,7 +30,15 @@ export const MetricInterceptor = (options, nextCall) => { return new grpc.InterceptingCall(nextCall(options), { start: function (metadata, listener, next) { // Record attempt metric on request start - const factory = MetricsTracerFactory.getInstance(); + const resourcePrefix = metadata.get( + 'google-cloud-resource-prefix', + )[0] as string; + const match = resourcePrefix?.match(/^projects\/([^/]+)\//); + const projectId = match ? match[1] : undefined; + let factory; + if (projectId) { + factory = MetricsTracerFactory.getInstance(projectId); + } const requestId = metadata.get('x-goog-spanner-request-id')[0] as string; const metricsTracer = factory?.getCurrentTracer(requestId); metricsTracer?.recordAttemptStart(); diff --git a/src/metrics/metrics-tracer-factory.ts b/src/metrics/metrics-tracer-factory.ts index 5b416ac60..d928c6fa8 100644 --- a/src/metrics/metrics-tracer-factory.ts +++ b/src/metrics/metrics-tracer-factory.ts @@ -56,7 +56,6 @@ export class MetricsTracerFactory { private _currentOperationTracers = new Map(); private _currentOperationLastUpdatedMs = new Map(); private _intervalTracerCleanup: NodeJS.Timeout; - public static _readers: MetricReader[] = []; public static enabled = true; /** @@ -101,7 +100,7 @@ export class MetricsTracerFactory { * @param projectId Optional GCP project ID for the factory instantiation. Does nothing for subsequent calls. * @returns The singleton MetricsTracerFactory instance or null if disabled. */ - public static getInstance(projectId = ''): MetricsTracerFactory | null { + public static getInstance(projectId: string): MetricsTracerFactory | null { if (!MetricsTracerFactory.enabled) { return null; } @@ -110,6 +109,7 @@ export class MetricsTracerFactory { if (MetricsTracerFactory._instance === null) { MetricsTracerFactory._instance = new MetricsTracerFactory(projectId); } + return MetricsTracerFactory!._instance; } @@ -128,7 +128,6 @@ export class MetricsTracerFactory { [Constants.MONITORED_RES_LABEL_KEY_INSTANCE]: 'unknown', [Constants.MONITORED_RES_LABEL_KEY_INSTANCE_CONFIG]: 'unknown', }); - MetricsTracerFactory._readers = readers; this._meterProvider = new MeterProvider({ resource: resource, readers: readers, @@ -142,7 +141,7 @@ export class MetricsTracerFactory { /** * Resets the singleton instance of the MetricsTracerFactory. */ - public static async resetInstance() { + public static async resetInstance(projectId?: string) { clearInterval(MetricsTracerFactory._instance?._intervalTracerCleanup); await MetricsTracerFactory._instance?.resetMeterProvider(); MetricsTracerFactory._instance = null; @@ -250,6 +249,7 @@ export class MetricsTracerFactory { MetricsTracerFactory.enabled, database, instance, + this._projectId, method, operationRequest, ); diff --git a/src/metrics/metrics-tracer.ts b/src/metrics/metrics-tracer.ts index ede4c7f7e..d41347a03 100644 --- a/src/metrics/metrics-tracer.ts +++ b/src/metrics/metrics-tracer.ts @@ -163,6 +163,7 @@ export class MetricsTracer { public enabled: boolean, private _database: string, private _instance: string, + private _projectId: string, private _methodName: string, private _request: string, ) { @@ -276,7 +277,9 @@ export class MetricsTracer { operationLatencyMilliseconds, operationAttributes, ); - MetricsTracerFactory.getInstance()!.clearCurrentTracer(this._request); + MetricsTracerFactory.getInstance(this._projectId)!.clearCurrentTracer( + this._request, + ); } /** diff --git a/src/metrics/spanner-metrics-exporter.ts b/src/metrics/spanner-metrics-exporter.ts index 1974e08b5..0a1ac7ae6 100644 --- a/src/metrics/spanner-metrics-exporter.ts +++ b/src/metrics/spanner-metrics-exporter.ts @@ -28,19 +28,15 @@ export const MAX_BATCH_EXPORT_SIZE = 200; * Format and sends metrics information to Google Cloud Monitoring. */ export class CloudMonitoringMetricsExporter implements PushMetricExporter { - private _projectId: string | void | Promise; + private _projectId: string; private _lastExported: Date = new Date(0); private readonly _client: MetricServiceClient; private _metricsExportFailureLogged = false; - constructor({auth}: ExporterOptions) { + constructor({auth}: ExporterOptions, projectId: string) { this._client = new MetricServiceClient({auth: auth}); - // Start this async process as early as possible. It will be - // awaited on the first export because constructors are synchronous - this._projectId = auth.getProjectId().catch(err => { - console.error(err); - }); + this._projectId = projectId; } /** @@ -83,18 +79,10 @@ export class CloudMonitoringMetricsExporter implements PushMetricExporter { private async _exportAsync( resourceMetrics: ResourceMetrics, ): Promise { - if (this._projectId instanceof Promise) { - this._projectId = await this._projectId; - } - - if (!this._projectId) { - const error = new Error('expecting a non-blank ProjectID'); - console.error(error.message); - return {code: ExportResultCode.FAILED, error}; - } - - const timeSeriesList = - transformResourceMetricToTimeSeriesArray(resourceMetrics); + const timeSeriesList = transformResourceMetricToTimeSeriesArray( + resourceMetrics, + this._projectId, + ); let failure: {sendFailed: false} | {sendFailed: true; error: Error} = { sendFailed: false, diff --git a/src/metrics/transform.ts b/src/metrics/transform.ts index 865ffff3e..71a0052c1 100644 --- a/src/metrics/transform.ts +++ b/src/metrics/transform.ts @@ -90,6 +90,7 @@ function _transformValueType(metric: MetricData): ValueType { */ export function transformResourceMetricToTimeSeriesArray( resourceMetrics: ResourceMetrics, + projectId: string, ) { const resource = resourceMetrics?.resource; const scopeMetrics = resourceMetrics?.scopeMetrics; @@ -107,7 +108,7 @@ export function transformResourceMetricToTimeSeriesArray( // Flatmap the data points in each metric to create a TimeSeries for each point .flatMap(metric => metric.dataPoints.flatMap(dataPoint => - _createTimeSeries(metric, dataPoint, resource), + _createTimeSeries(metric, dataPoint, resource, projectId), ), ) ); @@ -119,14 +120,15 @@ export function transformResourceMetricToTimeSeriesArray( function _createTimeSeries( metric: MetricData, dataPoint: DataPoint, - resource?: Resource, + resource: Resource, + projectId: string, ) { const type = path.posix.join(CLIENT_METRICS_PREFIX, metric.descriptor.name); const resourceLabels = resource - ? _extractLabels(resource) + ? _extractLabels(resource, projectId) : {metricLabels: {}, monitoredResourceLabels: {}}; - const dataLabels = _extractLabels(dataPoint); + const dataLabels = _extractLabels(dataPoint, projectId); const labels = { ...resourceLabels.metricLabels, @@ -205,8 +207,11 @@ function _transformPoint(metric: MetricData, dataPoint: DataPoint) { } /** Extracts metric and monitored resource labels from data point */ -function _extractLabels({attributes = {}}: DataPoint | Resource) { - const factory = MetricsTracerFactory.getInstance(); +function _extractLabels( + {attributes = {}}: DataPoint | Resource, + projectId: string, +) { + const factory = MetricsTracerFactory.getInstance(projectId); // Add Client name and Client UID metric labels attributes[METRIC_LABEL_KEY_CLIENT_UID] = factory?.clientUid ?? UNKNOWN_ATTRIBUTE; diff --git a/test/index.ts b/test/index.ts index c578247d2..3d0772a3e 100644 --- a/test/index.ts +++ b/test/index.ts @@ -51,20 +51,16 @@ assert.strictEqual(CLOUD_RESOURCE_HEADER, 'google-cloud-resource-prefix'); const apiConfig = require('../src/spanner_grpc_config.json'); async function disableMetrics(sandbox: sinon.SinonSandbox) { - if ( - Object.prototype.hasOwnProperty.call( - process.env, - 'SPANNER_DISABLE_BUILTIN_METRICS', - ) - ) { - sandbox.replace(process.env, 'SPANNER_DISABLE_BUILTIN_METRICS', 'true'); - } else { - sandbox.define(process.env, 'SPANNER_DISABLE_BUILTIN_METRICS', 'true'); - } + sandbox.stub(process.env, 'SPANNER_DISABLE_BUILTIN_METRICS').value('true'); await MetricsTracerFactory.resetInstance(); MetricsTracerFactory.enabled = false; } +async function enableMetrics(sandbox: sinon.SinonSandbox) { + sandbox.stub(process.env, 'SPANNER_DISABLE_BUILTIN_METRICS').value('false'); + await MetricsTracerFactory.resetInstance(); +} + function getFake(obj: {}) { return obj as { calledWith_: IArguments; @@ -133,7 +129,14 @@ const fakeV1: any = { function fakeGoogleAuth() { return { calledWith_: arguments, - getProjectId: () => Promise.resolve('project-id'), + getProjectId: function (callback) { + if (callback) { + callback(null, 'project-id'); + return; + } else { + return Promise.resolve('project-id'); + } + }, }; } @@ -328,6 +331,13 @@ describe('Spanner', () => { assert.strictEqual(spanner.routeToLeaderEnabled, false); }); + it('should configure metrics if project Id is not passed', async () => { + await enableMetrics(sandbox); + const spanner = new Spanner(); + assert.strictEqual(MetricsTracerFactory.enabled, true); + await disableMetrics(sandbox); + }); + it('should optionally accept disableBuiltInMetrics', () => { const spanner = new Spanner({disableBuiltInMetrics: true}); assert.strictEqual(MetricsTracerFactory.enabled, false); diff --git a/test/metrics/interceptor.ts b/test/metrics/interceptor.ts index 596ae06b3..fdb5cf889 100644 --- a/test/metrics/interceptor.ts +++ b/test/metrics/interceptor.ts @@ -117,6 +117,10 @@ describe('MetricInterceptor', () => { }, }; testMetadata = new grpc.Metadata(); + testMetadata.set( + 'google-cloud-resource-prefix', + 'projects/test-project/instances/instance/databases/database-1', + ); }); afterEach(() => { diff --git a/test/metrics/metrics-tracer-factory.ts b/test/metrics/metrics-tracer-factory.ts index ab5dcccf5..c5db377ae 100644 --- a/test/metrics/metrics-tracer-factory.ts +++ b/test/metrics/metrics-tracer-factory.ts @@ -93,7 +93,7 @@ describe('MetricsTracerFactory', () => { }); it('should use the set meter provider', async () => { - const factory = MetricsTracerFactory.getInstance(); + const factory = MetricsTracerFactory.getInstance('project-id'); const tracer = factory!.createMetricsTracer( 'some-method', 'projects/project/instances/instance/databases/database', @@ -126,7 +126,7 @@ describe('MetricsTracerFactory', () => { }); it('should initialize metric instruments when enabled', () => { - const factory = MetricsTracerFactory.getInstance(); + const factory = MetricsTracerFactory.getInstance('project-id'); assert.deepStrictEqual(factory!.instrumentAttemptLatency, { record: recordAttemptLatencyStub, @@ -149,7 +149,7 @@ describe('MetricsTracerFactory', () => { }); it('should create a MetricsTracer instance', () => { - const factory = MetricsTracerFactory.getInstance(); + const factory = MetricsTracerFactory.getInstance('project-id'); const tracer = factory!.createMetricsTracer( 'some-method', 'method-name', @@ -159,7 +159,7 @@ describe('MetricsTracerFactory', () => { }); it('should correctly set default attributes', () => { - const factory = MetricsTracerFactory.getInstance(); + const factory = MetricsTracerFactory.getInstance('project-id'); const tracer = factory!.createMetricsTracer( 'test-method', 'projects/project/instances/instance/databases/database', diff --git a/test/metrics/metrics-tracer.ts b/test/metrics/metrics-tracer.ts index 87bd90a2e..fef4e9099 100644 --- a/test/metrics/metrics-tracer.ts +++ b/test/metrics/metrics-tracer.ts @@ -23,6 +23,7 @@ import {Spanner} from '../../src'; const DATABASE = 'test-db'; const INSTANCE = 'instance'; +const PROJECT_ID = 'project_id'; const METHOD = 'test-method'; const REQUEST = 'test-request'; @@ -83,6 +84,7 @@ describe('MetricsTracer', () => { true, // enabled, DATABASE, INSTANCE, + PROJECT_ID, METHOD, REQUEST, ); @@ -249,6 +251,7 @@ describe('MetricsTracer', () => { true, DATABASE, INSTANCE, + PROJECT_ID, METHOD, REQUEST, ); diff --git a/test/metrics/metrics.ts b/test/metrics/metrics.ts index 61ae8a05a..0805f7451 100644 --- a/test/metrics/metrics.ts +++ b/test/metrics/metrics.ts @@ -42,6 +42,7 @@ describe('Test metrics with mock server', () => { const selectSql = 'SELECT NUM, NAME FROM NUMBERS'; const server = new grpc.Server(); const spannerMock = mock.createMockSpanner(server); + const PROJECT_ID = 'test-project'; class InMemoryMetricReader extends MetricReader { protected async onForceFlush(): Promise {} @@ -158,7 +159,7 @@ describe('Test metrics with mock server', () => { await MetricsTracerFactory.resetInstance(); MetricsTracerFactory.enabled = true; spanner = new Spanner({ - projectId: 'test-project', + projectId: PROJECT_ID, servicePath: 'localhost', port, sslCreds: grpc.credentials.createInsecure(), @@ -210,7 +211,7 @@ describe('Test metrics with mock server', () => { spannerMock.removeExecutionTimes(); // Reset the MetricsFactoryReader to an in-memory reader for the tests MetricsTracerFactory.enabled = true; - factory = MetricsTracerFactory.getInstance(); + factory = MetricsTracerFactory.getInstance(PROJECT_ID); await factory!.resetMeterProvider(); reader = new InMemoryMetricReader(); factory!.getMeterProvider([reader]); diff --git a/test/metrics/spanner-metrics-exporter.ts b/test/metrics/spanner-metrics-exporter.ts index ea99c72bc..cd1bb1787 100644 --- a/test/metrics/spanner-metrics-exporter.ts +++ b/test/metrics/spanner-metrics-exporter.ts @@ -41,12 +41,11 @@ const DATABASE_ID = 'test-db'; const LOCATION = 'test-location'; const auth = new GoogleAuth(); -auth.getProjectId = sinon.stub().resolves(PROJECT_ID); // Ensure custom exporter is valid describe('CustomExporter', () => { it('should construct an exporter', () => { - const exporter = new CloudMonitoringMetricsExporter({auth}); + const exporter = new CloudMonitoringMetricsExporter({auth}, PROJECT_ID); assert.ok(typeof exporter.export === 'function'); assert.ok(typeof exporter.shutdown === 'function'); }); @@ -59,21 +58,18 @@ describe('CustomExporter', () => { }, }); auth.getProjectId = sinon.stub().resolves(PROJECT_ID); - const exporter = new CloudMonitoringMetricsExporter({auth}); + const exporter = new CloudMonitoringMetricsExporter({auth}, PROJECT_ID); assert(exporter); - return (exporter['_projectId'] as Promise).then(id => { - assert.deepStrictEqual(id, PROJECT_ID); - }); }); it('should be able to shutdown', async () => { - const exporter = new CloudMonitoringMetricsExporter({auth}); + const exporter = new CloudMonitoringMetricsExporter({auth}, PROJECT_ID); await assert.doesNotReject(exporter.shutdown()); }); it('should be able to force flush', async () => { - const exporter = new CloudMonitoringMetricsExporter({auth}); + const exporter = new CloudMonitoringMetricsExporter({auth}, PROJECT_ID); await assert.doesNotReject(exporter.forceFlush()); }); }); @@ -97,7 +93,7 @@ describe('Export', () => { let exporter: CloudMonitoringMetricsExporter; beforeEach(() => { - exporter = new CloudMonitoringMetricsExporter({auth}); + exporter = new CloudMonitoringMetricsExporter({auth}, PROJECT_ID); reader = new InMemoryMetricReader(); const resource = new Resource({ ['project_id']: PROJECT_ID, diff --git a/test/metrics/transform.ts b/test/metrics/transform.ts index 369bf57b4..c9cadd050 100644 --- a/test/metrics/transform.ts +++ b/test/metrics/transform.ts @@ -243,8 +243,8 @@ describe('transform', () => { }); it('should extract metric and resource labels', () => { - const dataLabels = _extractLabels(sumDataPoint); - const resourceLabels = _extractLabels(resource); + const dataLabels = _extractLabels(sumDataPoint, 'project_id'); + const resourceLabels = _extractLabels(resource, 'project_id'); // Metric Labels assert.strictEqual(dataLabels.metricLabels['client_uid'], 'test_uid'); @@ -416,8 +416,10 @@ describe('transform', () => { if (errors.length !== 0) { throw errors; } - const timeseries = - transformResourceMetricToTimeSeriesArray(resourceMetrics); + const timeseries = transformResourceMetricToTimeSeriesArray( + resourceMetrics, + 'project_id', + ); assert.strictEqual(timeseries.length, 1); // Verify the contents of the TimeSeries @@ -457,8 +459,10 @@ describe('transform', () => { if (errors.length !== 0) { throw errors; } - const timeseries = - transformResourceMetricToTimeSeriesArray(resourceMetrics); + const timeseries = transformResourceMetricToTimeSeriesArray( + resourceMetrics, + 'project_id', + ); assert.strictEqual(timeseries.length, 0); }); From d121cf972749c4d077facc5742413e00fb7e8435 Mon Sep 17 00:00:00 2001 From: "gcf-owl-bot[bot]" <78513119+gcf-owl-bot[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:38:51 +0530 Subject: [PATCH 6/7] chore: disable renovate for Node github action YAML configs (#2431) chore: disable renovate for github action YAML configs Source-Link: https://github.com/googleapis/synthtool/commit/158d49d854395e4eca4706df556628c418037193 Post-Processor: gcr.io/cloud-devrel-public-resources/owlbot-nodejs:latest@sha256:bdf89cdfb5b791d382184a7a769862b15c38e94e7d82b268c58d40d8952720f2 Co-authored-by: Owl Bot Co-authored-by: alkatrivedi <58396306+alkatrivedi@users.noreply.github.com> --- .github/.OwlBot.lock.yaml | 4 ++-- .kokoro/release/publish.cfg | 2 +- README.md | 2 +- renovate.json | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index 50bc3b4e0..0bb507a61 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-nodejs:latest - digest: sha256:84adf917cad8f48c61227febebae7af619882d7c8863d6ab6290a77d45a372cf -# created: 2025-09-10T20:42:34.536728816Z + digest: sha256:bdf89cdfb5b791d382184a7a769862b15c38e94e7d82b268c58d40d8952720f2 +# created: 2025-10-03T19:51:38.870830821Z diff --git a/.kokoro/release/publish.cfg b/.kokoro/release/publish.cfg index bd526df13..585e813a5 100644 --- a/.kokoro/release/publish.cfg +++ b/.kokoro/release/publish.cfg @@ -38,7 +38,7 @@ env_vars: { value: "github/nodejs-spanner/.kokoro/publish.sh" } -# Store the packages we uploaded to npmjs.org and their corresponding +# Store the packages we uploaded to npmjs.com and their corresponding # package-lock.jsons in Placer. That way, we have a record of exactly # what we published, and which version of which tools we used to publish # it, which we can use to generate SBOMs and attestations. diff --git a/README.md b/README.md index 98724d036..2b29dfce7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # [Cloud Spanner: Node.js Client](https://github.com/googleapis/nodejs-spanner) [![release level](https://img.shields.io/badge/release%20level-stable-brightgreen.svg?style=flat)](https://cloud.google.com/terms/launch-stages) -[![npm version](https://img.shields.io/npm/v/@google-cloud/spanner.svg)](https://www.npmjs.org/package/@google-cloud/spanner) +[![npm version](https://img.shields.io/npm/v/@google-cloud/spanner.svg)](https://www.npmjs.com/package/@google-cloud/spanner) diff --git a/renovate.json b/renovate.json index c5c702cf4..f39fd3232 100644 --- a/renovate.json +++ b/renovate.json @@ -15,6 +15,10 @@ { "extends": "packages:linters", "groupName": "linters" + }, + { + "matchManagers": ["github-actions"], + "enabled": false } ], "ignoreDeps": ["typescript"] From 4f7f0c32bcb7e2425afcab3fce9d4158a3df4fec Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 07:34:27 +0000 Subject: [PATCH 7/7] chore(main): release 8.2.2 (#2426) :robot: I have created a release *beep* *boop* --- ## [8.2.2](https://togithub.com/googleapis/nodejs-spanner/compare/v8.2.1...v8.2.2) (2025-10-07) ### Bug Fixes * Correctly determine project ID for metrics export ([#2427](https://togithub.com/googleapis/nodejs-spanner/issues/2427)) ([0d63312](https://togithub.com/googleapis/nodejs-spanner/commit/0d633126a87c1274abfd59550cb94052a819fcaa)) * Metrics Export Error log ([#2425](https://togithub.com/googleapis/nodejs-spanner/issues/2425)) ([110923e](https://togithub.com/googleapis/nodejs-spanner/commit/110923ea1dc6f6c891e0f70406b3839224a25b9e)) --- This PR was generated with [Release Please](https://togithub.com/googleapis/release-please). See [documentation](https://togithub.com/googleapis/release-please#release-please). --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- samples/package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae37be336..a1b0b62f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ [1]: https://www.npmjs.com/package/nodejs-spanner?activeTab=versions +## [8.2.2](https://github.com/googleapis/nodejs-spanner/compare/v8.2.1...v8.2.2) (2025-10-07) + + +### Bug Fixes + +* Correctly determine project ID for metrics export ([#2427](https://github.com/googleapis/nodejs-spanner/issues/2427)) ([0d63312](https://github.com/googleapis/nodejs-spanner/commit/0d633126a87c1274abfd59550cb94052a819fcaa)) +* Metrics Export Error log ([#2425](https://github.com/googleapis/nodejs-spanner/issues/2425)) ([110923e](https://github.com/googleapis/nodejs-spanner/commit/110923ea1dc6f6c891e0f70406b3839224a25b9e)) + ## [8.2.1](https://github.com/googleapis/nodejs-spanner/compare/v8.2.0...v8.2.1) (2025-09-12) diff --git a/package.json b/package.json index eaa752bdd..cc6b147ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@google-cloud/spanner", "description": "Cloud Spanner Client Library for Node.js", - "version": "8.2.1", + "version": "8.2.2", "license": "Apache-2.0", "author": "Google Inc.", "engines": { diff --git a/samples/package.json b/samples/package.json index 63b37821f..48e994a7b 100644 --- a/samples/package.json +++ b/samples/package.json @@ -17,7 +17,7 @@ "dependencies": { "@google-cloud/kms": "^5.0.0", "@google-cloud/precise-date": "^5.0.0", - "@google-cloud/spanner": "^8.2.1", + "@google-cloud/spanner": "^8.2.2", "protobufjs": "^7.0.0", "yargs": "^17.0.0" },