From f3281239659313507838bbda192269e59b2f8f68 Mon Sep 17 00:00:00 2001 From: Rafael Ribeiro Raposo Date: Wed, 20 May 2026 18:20:40 +0200 Subject: [PATCH 1/2] feat(spanner): add setMetricsProjectId to fix metrics export on GKE shared VPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On GKE with shared VPC, SpannerOptions.getProjectId() defaults to the host project via the metadata server. While DatabaseId correctly routes database operations to the application project, the client-side metrics exporter uses getProjectId() — causing createServiceTimeSeries to target the wrong project and fail with permission errors. Changes: - Add setMetricsProjectId(String) to SpannerOptions.Builder - Add resolveMetricsProjectId() that prefers explicit value, falls back to getProjectId() - Update 3 metrics export call sites to use resolveMetricsProjectId() - Log WARNING in getDatabaseClient() when DatabaseId project differs from metrics project --- .../com/google/cloud/spanner/SpannerImpl.java | 13 +++++++ .../google/cloud/spanner/SpannerOptions.java | 34 +++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java index c201924dfbe7..b7eceee975b3 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java @@ -296,6 +296,19 @@ public InstanceAdminClient getInstanceAdminClient() { public DatabaseClient getDatabaseClient(DatabaseId db) { synchronized (this) { checkClosed(); + SpannerOptions opts = getOptions(); + String metricsProject = opts.resolveMetricsProjectId(); + if (opts.isEnableBuiltInMetrics() + && metricsProject != null + && !metricsProject.equals(db.getInstanceId().getProject())) { + logger.log( + Level.WARNING, + "DatabaseId project ''{0}'' differs from the project used for client-side metrics" + + " export ''{1}''. Metrics will be exported to ''{1}'', which may cause" + + " permission errors. Set SpannerOptions.Builder.setMetricsProjectId(\"{0}\")" + + " to fix this.", + new Object[] {db.getInstanceId().getProject(), metricsProject}); + } String clientId = null; if (dbClients.containsKey(db) && !dbClients.get(db).isValid()) { // Close the invalidated client and remove it. diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java index 9c542bc52365..277402eb69e0 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java @@ -306,6 +306,7 @@ static GcpChannelPoolOptions mergeWithDefaultChannelPoolOptions( private final boolean enableExtendedTracing; private final boolean enableEndToEndTracing; private final String monitoringHost; + private final String metricsProjectId; private final TransactionOptions defaultTransactionOptions; private final RequestOptions.ClientContext clientContext; @@ -991,6 +992,7 @@ protected SpannerOptions(Builder builder) { } enableEndToEndTracing = builder.enableEndToEndTracing; monitoringHost = builder.monitoringHost; + metricsProjectId = builder.metricsProjectId; defaultTransactionOptions = builder.defaultTransactionOptions; clientContext = builder.clientContext; } @@ -1251,6 +1253,7 @@ public static class Builder private boolean enableBuiltInMetrics = SpannerOptions.environment.isEnableBuiltInMetrics(); private boolean enableLocationApi = SpannerOptions.environment.isEnableLocationApi(); private String monitoringHost = SpannerOptions.environment.getMonitoringHost(); + private String metricsProjectId; private SslContext mTLSContext = null; private String experimentalHost = null; private boolean usePlainText = false; @@ -1360,6 +1363,7 @@ protected Builder() { this.enableLocationApi = options.enableLocationApi; this.enableEndToEndTracing = options.enableEndToEndTracing; this.monitoringHost = options.monitoringHost; + this.metricsProjectId = options.metricsProjectId; this.defaultTransactionOptions = options.defaultTransactionOptions; this.clientContext = options.clientContext; } @@ -2033,6 +2037,17 @@ public Builder setMonitoringHost(String monitoringHost) { return this; } + /** + * Sets the GCP project ID for exporting client-side built-in metrics. Defaults to {@link + * SpannerOptions#getProjectId()} when not set. On GKE with shared VPC, the default project may + * resolve to the host project instead of the application project; set this explicitly to avoid + * {@code monitoring.metricWriter} permission errors. + */ + public Builder setMetricsProjectId(String metricsProjectId) { + this.metricsProjectId = metricsProjectId; + return this; + } + /** * Sets whether to enable extended OpenTelemetry tracing. Enabling this option will add the * following additional attributes to the traces that are generated by the client: @@ -2443,14 +2458,14 @@ public ApiTracerFactory getApiTracerFactory() { @InternalApi public OpenTelemetry getBuiltInOpenTelemetry() { return this.builtInMetricsProvider.getOrCreateOpenTelemetry( - this.getProjectId(), getCredentials(), this.monitoringHost, getUniverseDomain()); + this.resolveMetricsProjectId(), getCredentials(), this.monitoringHost, getUniverseDomain()); } public void enablegRPCMetrics(InstantiatingGrpcChannelProvider.Builder channelProviderBuilder) { if (isEnableBuiltInMetrics() && SpannerOptions.environment.isEnableGRPCBuiltInMetrics()) { this.builtInMetricsProvider.enableGrpcMetrics( channelProviderBuilder, - this.getProjectId(), + this.resolveMetricsProjectId(), getCredentials(), this.monitoringHost, getUniverseDomain()); @@ -2499,7 +2514,7 @@ private ApiTracerFactory getDefaultApiTracerFactory() { private ApiTracerFactory createMetricsApiTracerFactory() { OpenTelemetry openTelemetry = this.builtInMetricsProvider.getOrCreateOpenTelemetry( - this.getProjectId(), getCredentials(), this.monitoringHost, getUniverseDomain()); + this.resolveMetricsProjectId(), getCredentials(), this.monitoringHost, getUniverseDomain()); return openTelemetry != null ? new BuiltInMetricsTracerFactory( @@ -2543,6 +2558,19 @@ String getMonitoringHost() { return monitoringHost; } + /** + * Returns the GCP project ID for client-side metrics export. Returns the explicit value if set + * via {@link Builder#setMetricsProjectId}, otherwise falls back to {@link #getProjectId()}. + */ + String resolveMetricsProjectId() { + return metricsProjectId != null ? metricsProjectId : getProjectId(); + } + + /** Returns the explicitly configured metrics project ID, or null if not set. */ + public String getMetricsProjectId() { + return metricsProjectId; + } + public TransactionOptions getDefaultTransactionOptions() { return defaultTransactionOptions; } From c5d7715ee11f9dd5e663951e23fdf87294466e6b Mon Sep 17 00:00:00 2001 From: Rafael Ribeiro Raposo Date: Wed, 20 May 2026 18:26:56 +0200 Subject: [PATCH 2/2] fix: move mismatch log to client creation path, avoid log spam Address review feedback: - Move project mismatch log inside the new-client branch so it fires at most once per DatabaseId, not on every getDatabaseClient - equals/hashCode: SpannerOptions inherits from ServiceOptions and does not override these methods; monitoringHost follows the same pattern --- .../com/google/cloud/spanner/SpannerImpl.java | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java index b7eceee975b3..868652fdba4b 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerImpl.java @@ -296,19 +296,6 @@ public InstanceAdminClient getInstanceAdminClient() { public DatabaseClient getDatabaseClient(DatabaseId db) { synchronized (this) { checkClosed(); - SpannerOptions opts = getOptions(); - String metricsProject = opts.resolveMetricsProjectId(); - if (opts.isEnableBuiltInMetrics() - && metricsProject != null - && !metricsProject.equals(db.getInstanceId().getProject())) { - logger.log( - Level.WARNING, - "DatabaseId project ''{0}'' differs from the project used for client-side metrics" - + " export ''{1}''. Metrics will be exported to ''{1}'', which may cause" - + " permission errors. Set SpannerOptions.Builder.setMetricsProjectId(\"{0}\")" - + " to fix this.", - new Object[] {db.getInstanceId().getProject(), metricsProject}); - } String clientId = null; if (dbClients.containsKey(db) && !dbClients.get(db).isValid()) { // Close the invalidated client and remove it. @@ -319,6 +306,17 @@ public DatabaseClient getDatabaseClient(DatabaseId db) { if (dbClients.containsKey(db)) { return dbClients.get(db); } else { + SpannerOptions opts = getOptions(); + String metricsProject = opts.resolveMetricsProjectId(); + if (opts.isEnableBuiltInMetrics() + && metricsProject != null + && !metricsProject.equals(db.getInstanceId().getProject())) { + logger.log( + Level.INFO, + "Built-in metrics project ''{0}'' differs from DatabaseId project ''{1}''." + + " Use setMetricsProjectId() to override if needed.", + new Object[] {metricsProject, db.getInstanceId().getProject()}); + } if (clientId == null) { clientId = nextDatabaseClientId(db); }