diff --git a/.github/release-please.yml b/.github/release-please.yml
index 4eb99a985a0..efc1cdc4b60 100644
--- a/.github/release-please.yml
+++ b/.github/release-please.yml
@@ -46,3 +46,7 @@ branches:
bumpMinorPreMajor: true
handleGHRelease: true
branch: 6.88.x
+ - releaseType: java-backport
+ bumpMinorPreMajor: true
+ handleGHRelease: true
+ branch: 6.96.x
diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml
index 4510689141c..3fe63bc890d 100644
--- a/.github/sync-repo-settings.yaml
+++ b/.github/sync-repo-settings.yaml
@@ -182,6 +182,27 @@ branchProtectionRules:
- units-with-multiplexed-session (11)
- unmanaged_dependency_check
- library_generation
+ - pattern: 6.96.x
+ isAdminEnforced: true
+ requiredApprovingReviewCount: 1
+ requiresCodeOwnerReviews: true
+ requiresStrictStatusChecks: false
+ requiredStatusCheckContexts:
+ - dependencies (17)
+ - lint
+ - javadoc
+ - units (8)
+ - units (11)
+ - 'Kokoro - Test: Integration'
+ - 'Kokoro - Test: Integration with Multiplexed Sessions'
+ - cla/google
+ - checkstyle
+ - compile (8)
+ - compile (11)
+ - units-with-multiplexed-session (8)
+ - units-with-multiplexed-session (11)
+ - unmanaged_dependency_check
+ - library_generation
permissionRules:
- team: yoshi-admins
permission: admin
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 849a6a8b339..f803387145b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [6.97.0](https://github.com/googleapis/java-spanner/compare/v6.96.1...v6.97.0) (2025-07-10)
+
+
+### Features
+
+* Next release from main branch is 6.97.0 ([#3984](https://github.com/googleapis/java-spanner/issues/3984)) ([5651f61](https://github.com/googleapis/java-spanner/commit/5651f6160e1e655f118aa2e7f0203a47cd6914c0))
+
+
+### Bug Fixes
+
+* Drop max message size ([#3987](https://github.com/googleapis/java-spanner/issues/3987)) ([3eee899](https://github.com/googleapis/java-spanner/commit/3eee89965547dfa49b4282b470f625d43c92f4fd))
+* Return non-empty metadata for DataBoost queries ([#3936](https://github.com/googleapis/java-spanner/issues/3936)) ([79c0684](https://github.com/googleapis/java-spanner/commit/79c06848c0ac4eff8410dd3bd63db8675c202d94))
+
## [6.96.1](https://github.com/googleapis/java-spanner/compare/v6.96.0...v6.96.1) (2025-06-30)
diff --git a/README.md b/README.md
index 47fe59a6bdf..971946c8fee 100644
--- a/README.md
+++ b/README.md
@@ -56,13 +56,13 @@ implementation 'com.google.cloud:google-cloud-spanner'
If you are using Gradle without BOM, add this to your dependencies:
```Groovy
-implementation 'com.google.cloud:google-cloud-spanner:6.96.1'
+implementation 'com.google.cloud:google-cloud-spanner:6.97.0'
```
If you are using SBT, add this to your dependencies:
```Scala
-libraryDependencies += "com.google.cloud" % "google-cloud-spanner" % "6.96.1"
+libraryDependencies += "com.google.cloud" % "google-cloud-spanner" % "6.97.0"
```
## Authentication
@@ -731,7 +731,7 @@ Java is a registered trademark of Oracle and/or its affiliates.
[kokoro-badge-link-5]: http://storage.googleapis.com/cloud-devrel-public/java/badges/java-spanner/java11.html
[stability-image]: https://img.shields.io/badge/stability-stable-green
[maven-version-image]: https://img.shields.io/maven-central/v/com.google.cloud/google-cloud-spanner.svg
-[maven-version-link]: https://central.sonatype.com/artifact/com.google.cloud/google-cloud-spanner/6.96.1
+[maven-version-link]: https://central.sonatype.com/artifact/com.google.cloud/google-cloud-spanner/6.97.0
[authentication]: https://github.com/googleapis/google-cloud-java#authentication
[auth-scopes]: https://developers.google.com/identity/protocols/oauth2/scopes
[predefined-iam-roles]: https://cloud.google.com/iam/docs/understanding-roles#predefined_roles
diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml
index 640cb5b50c3..2d2a1c73f33 100644
--- a/benchmarks/pom.xml
+++ b/benchmarks/pom.xml
@@ -24,7 +24,7 @@
com.google.cloud
google-cloud-spanner-parent
- 6.96.1
+ 6.97.0
diff --git a/google-cloud-spanner-bom/pom.xml b/google-cloud-spanner-bom/pom.xml
index 850809800ce..ef6c625f894 100644
--- a/google-cloud-spanner-bom/pom.xml
+++ b/google-cloud-spanner-bom/pom.xml
@@ -3,7 +3,7 @@
4.0.0
com.google.cloud
google-cloud-spanner-bom
- 6.96.1
+ 6.97.0
pom
com.google.cloud
@@ -53,43 +53,43 @@
com.google.cloud
google-cloud-spanner
- 6.96.1
+ 6.97.0
com.google.cloud
google-cloud-spanner
test-jar
- 6.96.1
+ 6.97.0
com.google.api.grpc
grpc-google-cloud-spanner-v1
- 6.96.1
+ 6.97.0
com.google.api.grpc
grpc-google-cloud-spanner-admin-instance-v1
- 6.96.1
+ 6.97.0
com.google.api.grpc
grpc-google-cloud-spanner-admin-database-v1
- 6.96.1
+ 6.97.0
com.google.api.grpc
proto-google-cloud-spanner-admin-instance-v1
- 6.96.1
+ 6.97.0
com.google.api.grpc
proto-google-cloud-spanner-v1
- 6.96.1
+ 6.97.0
com.google.api.grpc
proto-google-cloud-spanner-admin-database-v1
- 6.96.1
+ 6.97.0
diff --git a/google-cloud-spanner-executor/pom.xml b/google-cloud-spanner-executor/pom.xml
index 7e6607bbb06..f87e817da49 100644
--- a/google-cloud-spanner-executor/pom.xml
+++ b/google-cloud-spanner-executor/pom.xml
@@ -5,14 +5,14 @@
4.0.0
com.google.cloud
google-cloud-spanner-executor
- 6.96.1
+ 6.97.0
jar
Google Cloud Spanner Executor
com.google.cloud
google-cloud-spanner-parent
- 6.96.1
+ 6.97.0
diff --git a/google-cloud-spanner/pom.xml b/google-cloud-spanner/pom.xml
index a066eeb5758..1d038006cd3 100644
--- a/google-cloud-spanner/pom.xml
+++ b/google-cloud-spanner/pom.xml
@@ -3,7 +3,7 @@
4.0.0
com.google.cloud
google-cloud-spanner
- 6.96.1
+ 6.97.0
jar
Google Cloud Spanner
https://github.com/googleapis/java-spanner
@@ -11,7 +11,7 @@
com.google.cloud
google-cloud-spanner-parent
- 6.96.1
+ 6.97.0
google-cloud-spanner
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporter.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporter.java
index 53544672165..b43cf43b250 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporter.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporter.java
@@ -141,7 +141,8 @@ private CompletableResultCode exportSpannerClientMetrics(Collection
List spannerTimeSeries;
try {
spannerTimeSeries =
- SpannerCloudMonitoringExporterUtils.convertToSpannerTimeSeries(spannerMetricData);
+ SpannerCloudMonitoringExporterUtils.convertToSpannerTimeSeries(
+ spannerMetricData, this.spannerProjectId);
} catch (Throwable e) {
logger.log(
Level.WARNING,
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterUtils.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterUtils.java
index f67621db963..ba53fa02a6b 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterUtils.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterUtils.java
@@ -36,17 +36,24 @@
import com.google.api.MetricDescriptor.MetricKind;
import com.google.api.MetricDescriptor.ValueType;
import com.google.api.MonitoredResource;
+import com.google.monitoring.v3.DroppedLabels;
import com.google.monitoring.v3.Point;
+import com.google.monitoring.v3.SpanContext;
import com.google.monitoring.v3.TimeInterval;
import com.google.monitoring.v3.TimeSeries;
import com.google.monitoring.v3.TypedValue;
+import com.google.protobuf.Any;
+import com.google.protobuf.Timestamp;
import com.google.protobuf.util.Timestamps;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.sdk.metrics.data.AggregationTemporality;
+import io.opentelemetry.sdk.metrics.data.DoubleExemplarData;
import io.opentelemetry.sdk.metrics.data.DoublePointData;
+import io.opentelemetry.sdk.metrics.data.ExemplarData;
import io.opentelemetry.sdk.metrics.data.HistogramData;
import io.opentelemetry.sdk.metrics.data.HistogramPointData;
+import io.opentelemetry.sdk.metrics.data.LongExemplarData;
import io.opentelemetry.sdk.metrics.data.LongPointData;
import io.opentelemetry.sdk.metrics.data.MetricData;
import io.opentelemetry.sdk.metrics.data.MetricDataType;
@@ -57,6 +64,7 @@
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
+import java.util.stream.Collectors;
class SpannerCloudMonitoringExporterUtils {
@@ -69,7 +77,8 @@ static String getProjectId(Resource resource) {
return resource.getAttributes().get(PROJECT_ID_KEY);
}
- static List convertToSpannerTimeSeries(List collection) {
+ static List convertToSpannerTimeSeries(
+ List collection, String projectId) {
List allTimeSeries = new ArrayList<>();
for (MetricData metricData : collection) {
@@ -94,7 +103,8 @@ static List convertToSpannerTimeSeries(List collection)
metricData.getData().getPoints().stream()
.map(
pointData ->
- convertPointToSpannerTimeSeries(metricData, pointData, monitoredResourceBuilder))
+ convertPointToSpannerTimeSeries(
+ metricData, pointData, monitoredResourceBuilder, projectId))
.forEach(allTimeSeries::add);
}
return allTimeSeries;
@@ -103,7 +113,8 @@ static List convertToSpannerTimeSeries(List collection)
private static TimeSeries convertPointToSpannerTimeSeries(
MetricData metricData,
PointData pointData,
- MonitoredResource.Builder monitoredResourceBuilder) {
+ MonitoredResource.Builder monitoredResourceBuilder,
+ String projectId) {
TimeSeries.Builder builder =
TimeSeries.newBuilder()
.setMetricKind(convertMetricKind(metricData))
@@ -135,7 +146,7 @@ private static TimeSeries convertPointToSpannerTimeSeries(
.setEndTime(Timestamps.fromNanos(pointData.getEpochNanos()))
.build();
- builder.addPoints(createPoint(metricData.getType(), pointData, timeInterval));
+ builder.addPoints(createPoint(metricData.getType(), pointData, timeInterval, projectId));
return builder.build();
}
@@ -191,7 +202,7 @@ private static ValueType convertValueType(MetricDataType metricDataType) {
}
private static Point createPoint(
- MetricDataType type, PointData pointData, TimeInterval timeInterval) {
+ MetricDataType type, PointData pointData, TimeInterval timeInterval, String projectId) {
Point.Builder builder = Point.newBuilder().setInterval(timeInterval);
switch (type) {
case HISTOGRAM:
@@ -199,7 +210,8 @@ private static Point createPoint(
return builder
.setValue(
TypedValue.newBuilder()
- .setDistributionValue(convertHistogramData((HistogramPointData) pointData))
+ .setDistributionValue(
+ convertHistogramData((HistogramPointData) pointData, projectId))
.build())
.build();
case DOUBLE_GAUGE:
@@ -221,7 +233,7 @@ private static Point createPoint(
}
}
- private static Distribution convertHistogramData(HistogramPointData pointData) {
+ private static Distribution convertHistogramData(HistogramPointData pointData, String projectId) {
return Distribution.newBuilder()
.setCount(pointData.getCount())
.setMean(pointData.getCount() == 0L ? 0.0D : pointData.getSum() / pointData.getCount())
@@ -229,6 +241,65 @@ private static Distribution convertHistogramData(HistogramPointData pointData) {
BucketOptions.newBuilder()
.setExplicitBuckets(Explicit.newBuilder().addAllBounds(pointData.getBoundaries())))
.addAllBucketCounts(pointData.getCounts())
+ .addAllExemplars(
+ pointData.getExemplars().stream()
+ .map(e -> mapExemplar(e, projectId))
+ .collect(Collectors.toList()))
.build();
}
+
+ private static Distribution.Exemplar mapExemplar(ExemplarData exemplar, String projectId) {
+ double value = 0;
+ if (exemplar instanceof DoubleExemplarData) {
+ value = ((DoubleExemplarData) exemplar).getValue();
+ } else if (exemplar instanceof LongExemplarData) {
+ value = ((LongExemplarData) exemplar).getValue();
+ }
+
+ Distribution.Exemplar.Builder exemplarBuilder =
+ Distribution.Exemplar.newBuilder()
+ .setValue(value)
+ .setTimestamp(mapTimestamp(exemplar.getEpochNanos()));
+ if (exemplar.getSpanContext().isValid()) {
+ exemplarBuilder.addAttachments(
+ Any.pack(
+ SpanContext.newBuilder()
+ .setSpanName(
+ makeSpanName(
+ projectId,
+ exemplar.getSpanContext().getTraceId(),
+ exemplar.getSpanContext().getSpanId()))
+ .build()));
+ }
+ if (!exemplar.getFilteredAttributes().isEmpty()) {
+ exemplarBuilder.addAttachments(
+ Any.pack(mapFilteredAttributes(exemplar.getFilteredAttributes())));
+ }
+ return exemplarBuilder.build();
+ }
+
+ static final long NANO_PER_SECOND = (long) 1e9;
+
+ private static Timestamp mapTimestamp(long epochNanos) {
+ return Timestamp.newBuilder()
+ .setSeconds(epochNanos / NANO_PER_SECOND)
+ .setNanos((int) (epochNanos % NANO_PER_SECOND))
+ .build();
+ }
+
+ private static String makeSpanName(String projectId, String traceId, String spanId) {
+ return String.format("projects/%s/traces/%s/spans/%s", projectId, traceId, spanId);
+ }
+
+ private static DroppedLabels mapFilteredAttributes(Attributes attributes) {
+ DroppedLabels.Builder labels = DroppedLabels.newBuilder();
+ attributes.forEach((k, v) -> labels.putLabel(cleanAttributeKey(k.getKey()), v.toString()));
+ return labels.build();
+ }
+
+ private static String cleanAttributeKey(String key) {
+ // . is commonly used in OTel but disallowed in GCM label names,
+ // https://cloud.google.com/monitoring/api/ref_v3/rest/v3/LabelDescriptor#:~:text=Matches%20the%20following%20regular%20expression%3A
+ return key.replace('.', '_');
+ }
}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MergedResultSet.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MergedResultSet.java
index fcbc49f346d..1cbbf0818c5 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MergedResultSet.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MergedResultSet.java
@@ -25,6 +25,7 @@
import com.google.cloud.spanner.SpannerExceptionFactory;
import com.google.cloud.spanner.Struct;
import com.google.cloud.spanner.Type;
+import com.google.cloud.spanner.Type.Code;
import com.google.common.base.Preconditions;
import com.google.common.base.Supplier;
import com.google.spanner.v1.ResultSetMetadata;
@@ -82,9 +83,11 @@ public void run() {
break;
}
}
- if (first) {
- // Special case: The result set did not return any rows. Push the metadata to the merged
- // result set.
+ if (first
+ && resultSet.getType().getCode() == Code.STRUCT
+ && !resultSet.getType().getStructFields().isEmpty()) {
+ // Special case: The result set did not return any rows, but did return metadata.
+ // Push the metadata to the merged result set.
queue.put(
PartitionExecutorResult.typeAndMetadata(
resultSet.getType(), resultSet.getMetadata()));
@@ -319,13 +322,17 @@ public Struct get() {
return currentRow;
}
- private PartitionExecutorResult getFirstResult() {
+ private PartitionExecutorResult getFirstResultWithMetadata() {
try {
metadataAvailableLatch.await();
} catch (InterruptedException interruptedException) {
throw SpannerExceptionFactory.propagateInterrupt(interruptedException);
}
- PartitionExecutorResult result = queue.peek();
+ PartitionExecutorResult result =
+ queue.stream()
+ .filter(rs -> rs.metadata != null || rs.exception != null)
+ .findFirst()
+ .orElse(null);
if (result == null) {
throw SpannerExceptionFactory.newSpannerException(
ErrorCode.FAILED_PRECONDITION, "Thread-unsafe access to ResultSet");
@@ -338,7 +345,7 @@ private PartitionExecutorResult getFirstResult() {
public ResultSetMetadata getMetadata() {
if (metadata == null) {
- return getFirstResult().metadata;
+ return getFirstResultWithMetadata().metadata;
}
return metadata;
}
@@ -355,7 +362,7 @@ public int getParallelism() {
public Type getType() {
if (type == null) {
- return getFirstResult().type;
+ return getFirstResultWithMetadata().type;
}
return type;
}
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java
index 5abf3ea98e7..d3017afd618 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/GapicSpannerRpc.java
@@ -224,7 +224,7 @@ public class GapicSpannerRpc implements SpannerRpc {
PathTemplate.create("projects/{project}");
private static final PathTemplate OPERATION_NAME_TEMPLATE =
PathTemplate.create("{database=projects/*/instances/*/databases/*}/operations/{operation}");
- private static final int MAX_MESSAGE_SIZE = 100 * 1024 * 1024;
+ private static final int MAX_MESSAGE_SIZE = 256 * 1024 * 1024;
private static final int MAX_METADATA_SIZE = 32 * 1024; // bytes
private static final String PROPERTY_TIMEOUT_SECONDS =
"com.google.cloud.spanner.watchdogTimeoutSeconds";
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ITTransactionRetryTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ITTransactionRetryTest.java
new file mode 100644
index 00000000000..da48f661790
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/ITTransactionRetryTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.
+ */
+
+package com.google.cloud.spanner;
+
+import static com.google.cloud.spanner.testing.EmulatorSpannerHelper.isUsingEmulator;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeFalse;
+
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@Category(ParallelIntegrationTest.class)
+@RunWith(JUnit4.class)
+public class ITTransactionRetryTest {
+
+ @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv();
+
+ @Test
+ public void TestRetryInfo() {
+ assumeFalse("emulator does not support parallel transaction", isUsingEmulator());
+
+ // Creating a database with the table which contains INT64 columns
+ Database db =
+ env.getTestHelper()
+ .createTestDatabase("CREATE TABLE Test(ID INT64, " + "EMPID INT64) PRIMARY KEY (ID)");
+ DatabaseClient databaseClient = env.getTestHelper().getClient().getDatabaseClient(db.getId());
+
+ // Inserting one row
+ databaseClient
+ .readWriteTransaction()
+ .run(
+ transaction -> {
+ transaction.buffer(
+ Mutation.newInsertBuilder("Test").set("ID").to(1).set("EMPID").to(1).build());
+ return null;
+ });
+
+ int numRetries = 10;
+ boolean isAbortedWithRetryInfo = false;
+ while (numRetries-- > 0) {
+ try (TransactionManager transactionManager1 = databaseClient.transactionManager()) {
+ try (TransactionManager transactionManager2 = databaseClient.transactionManager()) {
+ try {
+ TransactionContext transaction1 = transactionManager1.begin();
+ TransactionContext transaction2 = transactionManager2.begin();
+ transaction1.executeUpdate(
+ Statement.of("UPDATE Test SET EMPID = EMPID + 1 WHERE ID = 1"));
+ transaction2.executeUpdate(
+ Statement.of("UPDATE Test SET EMPID = EMPID + 1 WHERE ID = 1"));
+ transactionManager1.commit();
+ transactionManager2.commit();
+ } catch (AbortedException abortedException) {
+ assertThat(abortedException.getErrorCode()).isEqualTo(ErrorCode.ABORTED);
+ if (abortedException.getRetryDelayInMillis() > 0) {
+ isAbortedWithRetryInfo = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ assertTrue("Transaction is not aborted with the trailers", isAbortedWithRetryInfo);
+ }
+}
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterTest.java
index 84a8cf4460c..8b8968bf4e1 100644
--- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterTest.java
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterTest.java
@@ -42,15 +42,21 @@
import com.google.cloud.monitoring.v3.stub.MetricServiceStub;
import com.google.common.collect.ImmutableList;
import com.google.monitoring.v3.CreateTimeSeriesRequest;
+import com.google.monitoring.v3.DroppedLabels;
import com.google.monitoring.v3.TimeSeries;
import com.google.protobuf.Empty;
import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.SpanContext;
+import io.opentelemetry.api.trace.TraceFlags;
+import io.opentelemetry.api.trace.TraceState;
import io.opentelemetry.sdk.common.InstrumentationScopeInfo;
import io.opentelemetry.sdk.metrics.InstrumentType;
import io.opentelemetry.sdk.metrics.data.AggregationTemporality;
+import io.opentelemetry.sdk.metrics.data.DoubleExemplarData;
import io.opentelemetry.sdk.metrics.data.HistogramPointData;
import io.opentelemetry.sdk.metrics.data.LongPointData;
import io.opentelemetry.sdk.metrics.data.MetricData;
+import io.opentelemetry.sdk.metrics.internal.data.ImmutableDoubleExemplarData;
import io.opentelemetry.sdk.metrics.internal.data.ImmutableHistogramData;
import io.opentelemetry.sdk.metrics.internal.data.ImmutableHistogramPointData;
import io.opentelemetry.sdk.metrics.internal.data.ImmutableLongPointData;
@@ -347,6 +353,86 @@ public void testExportingSumDataInBatches() {
}
}
+ @Test
+ public void testExportingHistogramDataWithExemplars() {
+ ArgumentCaptor argumentCaptor =
+ ArgumentCaptor.forClass(CreateTimeSeriesRequest.class);
+
+ UnaryCallable mockCallable = mock(UnaryCallable.class);
+ when(mockMetricServiceStub.createServiceTimeSeriesCallable()).thenReturn(mockCallable);
+ ApiFuture future = ApiFutures.immediateFuture(Empty.getDefaultInstance());
+ when(mockCallable.futureCall(argumentCaptor.capture())).thenReturn(future);
+
+ long startEpoch = 10 * 1_000_000_000L;
+ long endEpoch = 15 * 1_000_000_000L;
+ long recordTimeEpoch = 12_123_456_789L;
+
+ DoubleExemplarData exemplar =
+ ImmutableDoubleExemplarData.create(
+ Attributes.builder().put("request_id", "test").build(),
+ recordTimeEpoch,
+ SpanContext.create(
+ "0123456789abcdef0123456789abcdef",
+ "0123456789abcdef",
+ TraceFlags.getSampled(),
+ TraceState.getDefault()),
+ 1.5);
+
+ HistogramPointData histogramPointData =
+ ImmutableHistogramPointData.create(
+ startEpoch,
+ endEpoch,
+ attributes,
+ 3d,
+ true,
+ 1d,
+ true,
+ 2d,
+ Collections.singletonList(1.0),
+ Arrays.asList(1L, 2L),
+ Collections.singletonList(exemplar) // ← add exemplar
+ );
+
+ MetricData histogramData =
+ ImmutableMetricData.createDoubleHistogram(
+ resource,
+ scope,
+ "spanner.googleapis.com/internal/client/" + OPERATION_LATENCIES_NAME,
+ "description",
+ "ms",
+ ImmutableHistogramData.create(
+ AggregationTemporality.CUMULATIVE, ImmutableList.of(histogramPointData)));
+
+ exporter.export(Collections.singletonList(histogramData));
+ assertFalse(exporter.lastExportSkippedData());
+
+ CreateTimeSeriesRequest request = argumentCaptor.getValue();
+ TimeSeries timeSeries = request.getTimeSeriesList().get(0);
+ Distribution distribution = timeSeries.getPoints(0).getValue().getDistributionValue();
+
+ // Assert exemplar exists and has expected value
+ assertThat(distribution.getExemplarsCount()).isEqualTo(1);
+ Distribution.Exemplar exportedExemplar = distribution.getExemplars(0);
+ assertThat(exportedExemplar.getValue()).isEqualTo(1.5);
+
+ // Assert timestamp mapping
+ assertThat(exportedExemplar.getTimestamp().getSeconds())
+ .isEqualTo(recordTimeEpoch / 1_000_000_000L);
+ assertThat(exportedExemplar.getTimestamp().getNanos())
+ .isEqualTo((int) (recordTimeEpoch % 1_000_000_000L));
+
+ // Assert attachments: SpanContext
+ boolean hasSpanAttachment =
+ exportedExemplar.getAttachmentsList().stream()
+ .anyMatch(any -> any.is(com.google.monitoring.v3.SpanContext.class));
+ assertThat(hasSpanAttachment).isTrue();
+
+ // Assert attachments: DroppedLabels (filtered attributes)
+ boolean hasFilteredAttrs =
+ exportedExemplar.getAttachmentsList().stream().anyMatch(any -> any.is(DroppedLabels.class));
+ assertThat(hasFilteredAttrs).isTrue();
+ }
+
@Test
public void getAggregationTemporality() throws IOException {
SpannerCloudMonitoringExporter actualExporter =
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MergedResultSetTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MergedResultSetTest.java
index b0465be6106..6d3950efbc3 100644
--- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MergedResultSetTest.java
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MergedResultSetTest.java
@@ -32,6 +32,8 @@
import com.google.cloud.spanner.SpannerExceptionFactory;
import com.google.cloud.spanner.Struct;
import com.google.cloud.spanner.Type;
+import com.google.spanner.v1.ResultSetMetadata;
+import com.google.spanner.v1.StructType;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collection;
@@ -103,7 +105,7 @@ public static Collection