Skip to content

Commit a09b9ce

Browse files
feat(spanner): provide opt-in auto-tagging feature in client library
Spanner transaction and lock debugging heavily relies on transaction and request tags. This introduces an opt-in mechanism via `enableAutoTagTransactions()` in `SpannerOptions` to automatically discover and append tags from calling runtime stack frames when explicit tags are absent. Additionally, a dedicated emergency override environment variable `SPANNER_DISABLE_AUTO_TAGGING` is provided to allow disabling auto-tagging globally.
1 parent ca594e4 commit a09b9ce

6 files changed

Lines changed: 277 additions & 2 deletions

File tree

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/AbstractReadContext.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,7 @@ private void initTransactionInternal(BeginTransactionRequest request) {
644644
private final DirectedReadOptions defaultDirectedReadOptions;
645645
private final DecodeMode defaultDecodeMode;
646646
private final Clock clock;
647+
private volatile String cachedRequestTag;
647648

648649
@GuardedBy("lock")
649650
private boolean isValid = true;
@@ -831,6 +832,14 @@ QueryOptions buildQueryOptions(QueryOptions requestOptions) {
831832

832833
RequestOptions buildRequestOptions(Options options) {
833834
RequestOptions.Builder builder = options.toRequestOptionsProto(false).toBuilder();
835+
if (builder.getRequestTag().isEmpty()) {
836+
if (this.cachedRequestTag == null) {
837+
this.cachedRequestTag = AutoTagHelper.getAutoTag(session.getSpanner().getOptions());
838+
}
839+
if (this.cachedRequestTag != null) {
840+
builder.setRequestTag(this.cachedRequestTag);
841+
}
842+
}
834843
RequestOptions.ClientContext defaultClientContext =
835844
session.getSpanner().getOptions().getClientContext();
836845
if (defaultClientContext != null) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spanner;
18+
19+
import java.util.List;
20+
21+
class AutoTagHelper {
22+
23+
private static final String[] INTERNAL_PACKAGES = {
24+
"java.", "javax.", "jdk.", "sun.", "io.grpc.", "com.google.cloud.spanner.", "com.google.api."
25+
};
26+
27+
static String getAutoTag(SpannerOptions options) {
28+
if (!options.isAutoTagTransactionsEnabled()) {
29+
return null;
30+
}
31+
StackTraceElement[] stackTrace = new Throwable().getStackTrace();
32+
int limit = Math.min(stackTrace.length, options.getAutoTagTransactionsTracerLimit());
33+
List<String> targetPackages = options.getAutoTagTransactionsPackages();
34+
boolean hasTargetPackages = targetPackages != null && !targetPackages.isEmpty();
35+
36+
for (int i = 0; i < limit; i++) {
37+
StackTraceElement element = stackTrace[i];
38+
String className = element.getClassName();
39+
if (hasTargetPackages) {
40+
for (String targetPackage : targetPackages) {
41+
if (className.startsWith(targetPackage)) {
42+
return formatTag(className, element.getMethodName());
43+
}
44+
}
45+
} else {
46+
boolean isInternal = false;
47+
for (String internalPackage : INTERNAL_PACKAGES) {
48+
if (className.startsWith(internalPackage)) {
49+
isInternal = true;
50+
break;
51+
}
52+
}
53+
if (isInternal) {
54+
continue;
55+
}
56+
return formatTag(className, element.getMethodName());
57+
}
58+
}
59+
return null;
60+
}
61+
62+
private static String formatTag(String className, String methodName) {
63+
int lastDot = className.lastIndexOf('.');
64+
String simpleClassName = lastDot == -1 ? className : className.substring(lastDot + 1);
65+
String tag = simpleClassName + "." + methodName;
66+
if (tag.length() > 50) {
67+
tag = tag.substring(0, 50);
68+
}
69+
return tag;
70+
}
71+
}

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SessionImpl.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,19 @@ public CommitResponse writeAtLeastOnceWithOptions(
315315

316316
private RequestOptions getRequestOptions(TransactionOption... transactionOptions) {
317317
Options requestOptions = Options.fromTransactionOptions(transactionOptions);
318-
if (requestOptions.hasPriority() || requestOptions.hasTag()) {
318+
String autoTag = null;
319+
if (!requestOptions.hasTag()) {
320+
autoTag = AutoTagHelper.getAutoTag(spanner.getOptions());
321+
}
322+
if (requestOptions.hasPriority() || requestOptions.hasTag() || autoTag != null) {
319323
RequestOptions.Builder requestOptionsBuilder = RequestOptions.newBuilder();
320324
if (requestOptions.hasPriority()) {
321325
requestOptionsBuilder.setPriority(requestOptions.priority());
322326
}
323327
if (requestOptions.hasTag()) {
324328
requestOptionsBuilder.setTransactionTag(requestOptions.tag());
329+
} else if (autoTag != null) {
330+
requestOptionsBuilder.setTransactionTag(autoTag);
325331
}
326332
return requestOptionsBuilder.build();
327333
}
@@ -501,6 +507,12 @@ ApiFuture<Transaction> beginTransactionAsync(
501507
}
502508
RequestOptions.Builder optionsBuilder =
503509
transactionOptions.toRequestOptionsProto(true).toBuilder();
510+
if (!transactionOptions.hasTag()) {
511+
String autoTag = AutoTagHelper.getAutoTag(spanner.getOptions());
512+
if (autoTag != null) {
513+
optionsBuilder.setTransactionTag(autoTag);
514+
}
515+
}
504516
RequestOptions.ClientContext defaultClientContext = spanner.getOptions().getClientContext();
505517
if (defaultClientContext != null) {
506518
RequestOptions.ClientContext.Builder builder = defaultClientContext.toBuilder();

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerOptions.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
import java.time.Duration;
9797
import java.util.ArrayList;
9898
import java.util.Base64;
99+
import java.util.Collections;
99100
import java.util.HashMap;
100101
import java.util.List;
101102
import java.util.Map;
@@ -308,6 +309,9 @@ static GcpChannelPoolOptions mergeWithDefaultChannelPoolOptions(
308309
private final String monitoringHost;
309310
private final TransactionOptions defaultTransactionOptions;
310311
private final RequestOptions.ClientContext clientContext;
312+
private final boolean autoTagTransactionsEnabled;
313+
private final List<String> autoTagTransactionsPackages;
314+
private final int autoTagTransactionsTracerLimit;
311315

312316
enum TracingFramework {
313317
OPEN_CENSUS,
@@ -993,6 +997,9 @@ protected SpannerOptions(Builder builder) {
993997
monitoringHost = builder.monitoringHost;
994998
defaultTransactionOptions = builder.defaultTransactionOptions;
995999
clientContext = builder.clientContext;
1000+
autoTagTransactionsEnabled = builder.autoTagTransactionsEnabled;
1001+
autoTagTransactionsPackages = builder.autoTagTransactionsPackages;
1002+
autoTagTransactionsTracerLimit = builder.autoTagTransactionsTracerLimit;
9961003
}
9971004

9981005
private String getResolvedUniverseDomain() {
@@ -1064,6 +1071,10 @@ default boolean isEnableLocationApi() {
10641071
return false;
10651072
}
10661073

1074+
default boolean isAutoTagTransactionsDisabled() {
1075+
return false;
1076+
}
1077+
10671078
@Deprecated
10681079
@ObsoleteApi(
10691080
"This will be removed in an upcoming version without a major version bump. You should use"
@@ -1168,6 +1179,12 @@ public boolean isEnableLocationApi() {
11681179
return Boolean.parseBoolean(System.getenv(EXPERIMENTAL_LOCATION_API_ENV_VAR));
11691180
}
11701181

1182+
@Override
1183+
public boolean isAutoTagTransactionsDisabled() {
1184+
return Boolean.parseBoolean(System.getenv("SPANNER_DISABLE_AUTO_TAGGING"))
1185+
|| Boolean.parseBoolean(System.getProperty("spanner.disable_auto_tagging"));
1186+
}
1187+
11711188
@Override
11721189
public String getMonitoringHost() {
11731190
return System.getenv(SPANNER_MONITORING_HOST);
@@ -1256,6 +1273,9 @@ public static class Builder
12561273
private boolean usePlainText = false;
12571274
private TransactionOptions defaultTransactionOptions = TransactionOptions.getDefaultInstance();
12581275
private RequestOptions.ClientContext clientContext;
1276+
private boolean autoTagTransactionsEnabled = false;
1277+
private List<String> autoTagTransactionsPackages = Collections.emptyList();
1278+
private int autoTagTransactionsTracerLimit = 50;
12591279

12601280
private static String createCustomClientLibToken(String token) {
12611281
return token + " " + ServiceOptions.getGoogApiClientLibName();
@@ -1362,6 +1382,9 @@ protected Builder() {
13621382
this.monitoringHost = options.monitoringHost;
13631383
this.defaultTransactionOptions = options.defaultTransactionOptions;
13641384
this.clientContext = options.clientContext;
1385+
this.autoTagTransactionsEnabled = options.autoTagTransactionsEnabled;
1386+
this.autoTagTransactionsPackages = options.autoTagTransactionsPackages;
1387+
this.autoTagTransactionsTracerLimit = options.autoTagTransactionsTracerLimit;
13651388
}
13661389

13671390
@Override
@@ -2120,6 +2143,33 @@ public Builder setDefaultClientContext(RequestOptions.ClientContext clientContex
21202143
return this;
21212144
}
21222145

2146+
public Builder enableAutoTagTransactions() {
2147+
this.autoTagTransactionsEnabled = true;
2148+
return this;
2149+
}
2150+
2151+
public Builder disableAutoTagTransactions() {
2152+
this.autoTagTransactionsEnabled = false;
2153+
return this;
2154+
}
2155+
2156+
public Builder setAutoTagTransactionsPackage(String autoTagTransactionsPackage) {
2157+
this.autoTagTransactionsPackages = Collections.singletonList(autoTagTransactionsPackage);
2158+
return this;
2159+
}
2160+
2161+
public Builder setAutoTagTransactionsPackages(List<String> autoTagTransactionsPackages) {
2162+
this.autoTagTransactionsPackages =
2163+
Collections.unmodifiableList(
2164+
new ArrayList<>(Preconditions.checkNotNull(autoTagTransactionsPackages)));
2165+
return this;
2166+
}
2167+
2168+
public Builder setAutoTagTransactionsTracerLimit(int autoTagTransactionsTracerLimit) {
2169+
this.autoTagTransactionsTracerLimit = autoTagTransactionsTracerLimit;
2170+
return this;
2171+
}
2172+
21232173
@SuppressWarnings("rawtypes")
21242174
@Override
21252175
public SpannerOptions build() {
@@ -2547,6 +2597,22 @@ public TransactionOptions getDefaultTransactionOptions() {
25472597
return defaultTransactionOptions;
25482598
}
25492599

2600+
public boolean isAutoTagTransactionsEnabled() {
2601+
return autoTagTransactionsEnabled && !environment.isAutoTagTransactionsDisabled();
2602+
}
2603+
2604+
public List<String> getAutoTagTransactionsPackages() {
2605+
return autoTagTransactionsPackages;
2606+
}
2607+
2608+
public int getAutoTagTransactionsTracerLimit() {
2609+
return autoTagTransactionsTracerLimit;
2610+
}
2611+
2612+
public boolean isAutoTagTransactionsDisabled() {
2613+
return environment.isAutoTagTransactionsDisabled();
2614+
}
2615+
25502616
@BetaApi
25512617
public boolean isUseVirtualThreads() {
25522618
return useVirtualThreads;

java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/TransactionRunnerImpl.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ public void removeListener(Runnable listener) {
198198
private boolean aborted;
199199

200200
private final Options options;
201+
private volatile String cachedTransactionTag;
201202

202203
/** Default to -1 to indicate not available. */
203204
@GuardedBy("lock")
@@ -780,7 +781,10 @@ String getTransactionTag() {
780781
if (this.options.hasTag()) {
781782
return this.options.tag();
782783
}
783-
return null;
784+
if (this.cachedTransactionTag == null) {
785+
this.cachedTransactionTag = AutoTagHelper.getAutoTag(session.getSpanner().getOptions());
786+
}
787+
return this.cachedTransactionTag;
784788
}
785789

786790
@Nullable
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spanner;
18+
19+
import static org.junit.Assert.assertEquals;
20+
21+
import com.google.cloud.NoCredentials;
22+
import com.google.spanner.v1.CommitRequest;
23+
import com.google.spanner.v1.ExecuteSqlRequest;
24+
import org.junit.Before;
25+
import org.junit.Test;
26+
import org.junit.runner.RunWith;
27+
import org.junit.runners.JUnit4;
28+
29+
@RunWith(JUnit4.class)
30+
public class MockAutoTaggingTest extends AbstractMockServerTest {
31+
32+
@Before
33+
public void createSpannerInstanceWithAutoTagging() {
34+
// Overrides spanner instance in base class to enable auto-tagging targeting org.junit
35+
spanner =
36+
SpannerOptions.newBuilder()
37+
.setProjectId("test-project")
38+
.setChannelProvider(channelProvider)
39+
.setCredentials(NoCredentials.getInstance())
40+
.enableAutoTagTransactions()
41+
.setAutoTagTransactionsPackage("org.junit.runners")
42+
.setAutoTagTransactionsTracerLimit(200)
43+
.build()
44+
.getService();
45+
}
46+
47+
@Test
48+
public void testReadWriteTransactionAutoTagging() {
49+
DatabaseClient databaseClient = spanner.getDatabaseClient(DatabaseId.of("proj", "inst", "db"));
50+
mockSpanner.putStatementResult(
51+
MockSpannerServiceImpl.StatementResult.update(
52+
Statement.of("UPDATE Venues SET Capacity = 100"), 1L));
53+
54+
databaseClient
55+
.readWriteTransaction()
56+
.run(
57+
transaction -> {
58+
transaction.executeUpdate(Statement.of("UPDATE Venues SET Capacity = 100"));
59+
return null;
60+
});
61+
62+
// 1. Verify transaction tag is successfully populated on the ExecuteSqlRequest
63+
assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
64+
ExecuteSqlRequest sqlRequest = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
65+
String transactionTag = sqlRequest.getRequestOptions().getTransactionTag();
66+
assertEquals("FrameworkMethod$1.runReflectiveCall", transactionTag);
67+
68+
// 2. Verify transaction tag is successfully populated on CommitRequest and matches
69+
assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
70+
CommitRequest commitRequest = mockSpanner.getRequestsOfType(CommitRequest.class).get(0);
71+
assertEquals(transactionTag, commitRequest.getRequestOptions().getTransactionTag());
72+
}
73+
74+
@Test
75+
public void testSingleUseQueryAutoTagging() {
76+
DatabaseClient databaseClient = spanner.getDatabaseClient(DatabaseId.of("proj", "inst", "db"));
77+
com.google.spanner.v1.ResultSet select1ResultSet =
78+
com.google.spanner.v1.ResultSet.newBuilder()
79+
.addRows(
80+
com.google.protobuf.ListValue.newBuilder()
81+
.addValues(com.google.protobuf.Value.newBuilder().setStringValue("1").build())
82+
.build())
83+
.setMetadata(
84+
com.google.spanner.v1.ResultSetMetadata.newBuilder()
85+
.setRowType(
86+
com.google.spanner.v1.StructType.newBuilder()
87+
.addFields(
88+
com.google.spanner.v1.StructType.Field.newBuilder()
89+
.setName("COL1")
90+
.setType(
91+
com.google.spanner.v1.Type.newBuilder()
92+
.setCode(com.google.spanner.v1.TypeCode.INT64)
93+
.build())
94+
.build())
95+
.build())
96+
.build())
97+
.build();
98+
mockSpanner.putStatementResult(
99+
MockSpannerServiceImpl.StatementResult.query(
100+
Statement.of("SELECT * FROM Albums"), select1ResultSet));
101+
102+
try (ResultSet resultSet =
103+
databaseClient.singleUse().executeQuery(Statement.of("SELECT * FROM Albums"))) {
104+
while (resultSet.next()) {}
105+
}
106+
107+
// Verify request tag is populated on ExecuteSqlRequest and matches junit
108+
assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
109+
ExecuteSqlRequest sqlRequest = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
110+
String requestTag = sqlRequest.getRequestOptions().getRequestTag();
111+
assertEquals("FrameworkMethod$1.runReflectiveCall", requestTag);
112+
}
113+
}

0 commit comments

Comments
 (0)