entry : snapshot.entrySet()) {
+ TelemetryAccumulator failed = entry.getValue();
+
+ TelemetryAccumulator merged =
+ metricsMap.computeIfPresent(
+ entry.getKey(),
+ (k, v) -> {
+ v.merge(failed);
+ return v;
+ });
+ if (merged != null) {
+ continue;
+ }
+
+ if (metricsMap.size() >= maxUniqueProfiles) {
+ dropped++;
+ continue;
+ }
+ // A caller thread may have added the same key since computeIfPresent; merge into theirs.
+ TelemetryAccumulator raced = metricsMap.putIfAbsent(entry.getKey(), failed);
+ if (raced != null) {
+ raced.merge(failed);
+ }
+ }
+ if (dropped > 0) {
+ logger.log(
+ Level.FINE,
+ String.format("Dropped %d telemetry profiles on re-queue; profile cap reached", dropped));
+ }
+ }
+
+ private void reschedule(long delayMs) {
+ if (isClosed.get()) {
+ return;
+ }
+ long current = currentScheduleDelayMs.get();
+ if (current == delayMs && scheduledTask != null && !scheduledTask.isDone()) {
+ return;
+ }
+ if (scheduledTask != null) {
+ scheduledTask.cancel(false);
+ }
+ if (executorService != null && !executorService.isShutdown()) {
+ currentScheduleDelayMs.set(delayMs);
+ scheduledTask = executorService.schedule(this::flush, delayMs, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ // Telemetry Interfaces
+ interface TelemetryKey {
+ TelemetryAccumulator createAccumulator();
+
+ static TelemetryKey from(Message message) {
+ if (message instanceof StatementExecution) {
+ return new StatementKey((StatementExecution) message);
+ } else if (message instanceof ConnectionAttempt) {
+ return new ConnectionKey((ConnectionAttempt) message);
+ } else if (message instanceof ErrorMetric) {
+ return new ErrorKey((ErrorMetric) message);
+ } else if (message instanceof FeatureUsage) {
+ return new FeatureKey((FeatureUsage) message);
+ }
+ throw new IllegalArgumentException("Unsupported metric type: " + message.getClass());
+ }
+ }
+
+ interface TelemetryAccumulator {
+ void accumulate(long value);
+
+ void merge(TelemetryAccumulator other);
+
+ void addToPayload(TelemetryPayload.Builder payloadBuilder);
+ }
+
+ // Telemetry Keys
+ static final class StatementKey implements TelemetryKey {
+ final StatementType type;
+ final QueryApiType api;
+ final Status status;
+ final int errorCode;
+
+ StatementKey(StatementExecution statementExecution) {
+ this.type = statementExecution.getStatementType();
+ this.api = statementExecution.getQueryApiType();
+ this.status = statementExecution.getStatus();
+ this.errorCode = statementExecution.getErrorCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof StatementKey)) {
+ return false;
+ }
+ StatementKey that = (StatementKey) o;
+ return errorCode == that.errorCode
+ && type == that.type
+ && api == that.api
+ && status == that.status;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(type, api, status, errorCode);
+ }
+
+ @Override
+ public TelemetryAccumulator createAccumulator() {
+ return new StatementAccumulator(this);
+ }
+
+ StatementExecution.Builder toBuilder() {
+ return StatementExecution.newBuilder()
+ .setStatementType(type)
+ .setQueryApiType(api)
+ .setStatus(status)
+ .setErrorCode(errorCode);
+ }
+ }
+
+ static final class ConnectionKey implements TelemetryKey {
+ final AuthenticationType authType;
+ final Status status;
+ final int errorCode;
+
+ ConnectionKey(ConnectionAttempt connectionAttempt) {
+ this.authType = connectionAttempt.getAuthType();
+ this.status = connectionAttempt.getStatus();
+ this.errorCode = connectionAttempt.getErrorCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ConnectionKey)) {
+ return false;
+ }
+ ConnectionKey that = (ConnectionKey) o;
+ return errorCode == that.errorCode && authType == that.authType && status == that.status;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(authType, status, errorCode);
+ }
+
+ @Override
+ public TelemetryAccumulator createAccumulator() {
+ return new ConnectionAccumulator(this);
+ }
+
+ ConnectionAttempt.Builder toBuilder() {
+ return ConnectionAttempt.newBuilder()
+ .setAuthType(authType)
+ .setStatus(status)
+ .setErrorCode(errorCode);
+ }
+ }
+
+ static final class ErrorKey implements TelemetryKey {
+ final int errorCode;
+ final int errorXdbcCode;
+ final String errorSqlState;
+ final String methodName;
+
+ ErrorKey(ErrorMetric errorMetric) {
+ this.errorCode = errorMetric.getErrorCode();
+ this.errorXdbcCode = errorMetric.getErrorXdbcCode();
+ this.errorSqlState = errorMetric.getErrorSqlState();
+ this.methodName = errorMetric.getMethodName();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ErrorKey)) {
+ return false;
+ }
+ ErrorKey errorKey = (ErrorKey) o;
+ return errorCode == errorKey.errorCode
+ && errorXdbcCode == errorKey.errorXdbcCode
+ && Objects.equals(errorSqlState, errorKey.errorSqlState)
+ && Objects.equals(methodName, errorKey.methodName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(errorCode, errorXdbcCode, errorSqlState, methodName);
+ }
+
+ @Override
+ public TelemetryAccumulator createAccumulator() {
+ return new ErrorAccumulator(this);
+ }
+
+ ErrorMetric.Builder toBuilder() {
+ return ErrorMetric.newBuilder()
+ .setErrorCode(errorCode)
+ .setErrorXdbcCode(errorXdbcCode)
+ .setErrorSqlState(errorSqlState == null ? "" : errorSqlState)
+ .setMethodName(methodName);
+ }
+ }
+
+ static final class FeatureKey implements TelemetryKey {
+ final DriverFeature driverFeature;
+ final String customFeatureName;
+
+ FeatureKey(FeatureUsage featureUsage) {
+ this.driverFeature = featureUsage.getDriverFeature();
+ this.customFeatureName = featureUsage.getCustomFeatureName();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof FeatureKey)) {
+ return false;
+ }
+ FeatureKey that = (FeatureKey) o;
+ return driverFeature == that.driverFeature
+ && Objects.equals(customFeatureName, that.customFeatureName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(driverFeature, customFeatureName);
+ }
+
+ @Override
+ public TelemetryAccumulator createAccumulator() {
+ return new FeatureAccumulator(this);
+ }
+
+ FeatureUsage.Builder toBuilder() {
+ return FeatureUsage.newBuilder()
+ .setDriverFeature(driverFeature)
+ .setCustomFeatureName(customFeatureName == null ? "" : customFeatureName);
+ }
+ }
+
+ // Telemetry Accumulators
+ abstract static class CountAccumulator implements TelemetryAccumulator {
+ protected final LongAdder count = new LongAdder();
+
+ @Override
+ public void accumulate(long unused) {
+ count.increment();
+ }
+
+ @Override
+ public void merge(TelemetryAccumulator other) {
+ this.count.add(((CountAccumulator) other).count.sum());
+ }
+ }
+
+ static final class StatementAccumulator implements TelemetryAccumulator {
+ @VisibleForTesting
+ static final double[] HISTOGRAM_BOUNDS = {
+ 50.0, 100.0, 250.0, 500.0, 750.0, 1000.0, 2500.0, 5000.0, 10000.0, 30000.0, 60000.0, 120000.0,
+ 300000.0, 600000.0, 1200000.0, 1800000.0, 3600000.0
+ };
+
+ private final StatementKey key;
+ private final LongAdder count = new LongAdder();
+ private final LongAdder durationSum = new LongAdder();
+ private final LongAdder[] bucketCounts = new LongAdder[HISTOGRAM_BOUNDS.length + 1];
+
+ StatementAccumulator(StatementKey key) {
+ this.key = key;
+ for (int i = 0; i < bucketCounts.length; i++) {
+ bucketCounts[i] = new LongAdder();
+ }
+ }
+
+ @Override
+ public void accumulate(long durationMs) {
+ count.increment();
+ durationSum.add(durationMs);
+ int bucket = calculateBucket(durationMs);
+ bucketCounts[bucket].increment();
+ }
+
+ @VisibleForTesting
+ static int calculateBucket(long durationMs) {
+ for (int i = 0; i < HISTOGRAM_BOUNDS.length; i++) {
+ if (durationMs < HISTOGRAM_BOUNDS[i]) {
+ return i;
+ }
+ }
+ return HISTOGRAM_BOUNDS.length;
+ }
+
+ @Override
+ public void merge(TelemetryAccumulator other) {
+ StatementAccumulator o = (StatementAccumulator) other;
+ count.add(o.count.sum());
+ durationSum.add(o.durationSum.sum());
+ for (int i = 0; i < bucketCounts.length; i++) {
+ bucketCounts[i].add(o.bucketCounts[i].sum());
+ }
+ }
+
+ @Override
+ public void addToPayload(TelemetryPayload.Builder payloadBuilder) {
+ long totalCount = count.sum();
+ DurationHistogram.Builder durBuilder =
+ DurationHistogram.newBuilder().setCount(totalCount).setSum(durationSum.sum());
+
+ for (double bound : HISTOGRAM_BOUNDS) {
+ durBuilder.addExplicitBounds(bound);
+ }
+ for (LongAdder bucket : bucketCounts) {
+ durBuilder.addBucketCounts(bucket.sum());
+ }
+
+ payloadBuilder.addStatementExecutions(
+ key.toBuilder().setCount(totalCount).setDuration(durBuilder).build());
+ }
+ }
+
+ static final class ConnectionAccumulator extends CountAccumulator {
+ final ConnectionKey key;
+
+ ConnectionAccumulator(ConnectionKey key) {
+ this.key = key;
+ }
+
+ @Override
+ public void addToPayload(TelemetryPayload.Builder payloadBuilder) {
+ payloadBuilder.addConnectionAttempts(key.toBuilder().setCount(count.sum()).build());
+ }
+ }
+
+ static final class ErrorAccumulator extends CountAccumulator {
+ final ErrorKey key;
+
+ ErrorAccumulator(ErrorKey key) {
+ this.key = key;
+ }
+
+ @Override
+ public void addToPayload(TelemetryPayload.Builder payloadBuilder) {
+ payloadBuilder.addErrors(key.toBuilder().setCount(count.sum()).build());
+ }
+ }
+
+ static final class FeatureAccumulator extends CountAccumulator {
+ final FeatureKey key;
+
+ FeatureAccumulator(FeatureKey key) {
+ this.key = key;
+ }
+
+ @Override
+ public void addToPayload(TelemetryPayload.Builder payloadBuilder) {
+ payloadBuilder.addFeatureUsages(key.toBuilder().setCount(count.sum()).build());
+ }
+ }
+}
diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryConfiguration.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryConfiguration.java
new file mode 100644
index 000000000000..72ce8fe396fe
--- /dev/null
+++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryConfiguration.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import java.util.Objects;
+import java.util.Properties;
+
+/** Configuration settings for the BigQuery JDBC driver telemetry client. */
+final class TelemetryConfiguration {
+
+ static final int DEFAULT_LOG_SOURCE = 3071;
+ static final int DEFAULT_CLIENT_TYPE = 52;
+ static final String DEFAULT_ENDPOINT_URL = "https://play.googleapis.com/log";
+
+ private final boolean enabled;
+ private final int logSource;
+ private final String endpointUrl;
+ private final long uploadIntervalMs;
+ private final int batchSizeThreshold;
+ private final DriverEnvironment driverEnvironment;
+
+ private TelemetryConfiguration(Builder builder) {
+ this.enabled = builder.enabled;
+ this.logSource = builder.logSource;
+ this.endpointUrl = builder.endpointUrl;
+ this.uploadIntervalMs = builder.uploadIntervalMs;
+ this.batchSizeThreshold = builder.batchSizeThreshold;
+ this.driverEnvironment = builder.driverEnvironment;
+ }
+
+ boolean isEnabled() {
+ return enabled;
+ }
+
+ int getLogSource() {
+ return logSource;
+ }
+
+ String getEndpointUrl() {
+ return endpointUrl;
+ }
+
+ long getUploadIntervalMs() {
+ return uploadIntervalMs;
+ }
+
+ int getBatchSizeThreshold() {
+ return batchSizeThreshold;
+ }
+
+ DriverEnvironment getDriverEnvironment() {
+ return driverEnvironment;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ TelemetryConfiguration that = (TelemetryConfiguration) o;
+ return enabled == that.enabled
+ && logSource == that.logSource
+ && uploadIntervalMs == that.uploadIntervalMs
+ && batchSizeThreshold == that.batchSizeThreshold
+ && Objects.equals(endpointUrl, that.endpointUrl)
+ && Objects.equals(driverEnvironment, that.driverEnvironment);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ enabled, logSource, endpointUrl, uploadIntervalMs, batchSizeThreshold, driverEnvironment);
+ }
+
+ @Override
+ public String toString() {
+ return "TelemetryConfiguration{"
+ + "enabled="
+ + enabled
+ + ", logSource="
+ + logSource
+ + ", endpointUrl='"
+ + endpointUrl
+ + '\''
+ + ", uploadIntervalMs="
+ + uploadIntervalMs
+ + ", batchSizeThreshold="
+ + batchSizeThreshold
+ + ", driverEnvironment="
+ + driverEnvironment
+ + '}';
+ }
+
+ static Builder newBuilder() {
+ return new Builder();
+ }
+
+ static Builder builder() {
+ return new Builder();
+ }
+
+ /** Builder for {@link TelemetryConfiguration}. */
+ static class Builder {
+ private boolean enabled = TelemetryPropertyUtility.DEFAULT_ENABLE_DIAGNOSTIC_TELEMETRY_VALUE;
+ private int logSource = DEFAULT_LOG_SOURCE;
+ private String endpointUrl = DEFAULT_ENDPOINT_URL;
+ private long uploadIntervalMs =
+ TelemetryPropertyUtility.DEFAULT_TELEMETRY_UPLOAD_INTERVAL_VALUE;
+ private int batchSizeThreshold = TelemetryPropertyUtility.DEFAULT_TELEMETRY_BATCH_SIZE_VALUE;
+ private DriverEnvironment driverEnvironment;
+
+ Builder setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ return this;
+ }
+
+ Builder setLogSource(int logSource) {
+ this.logSource = logSource;
+ return this;
+ }
+
+ Builder setEndpointUrl(String endpointUrl) {
+ this.endpointUrl = endpointUrl;
+ return this;
+ }
+
+ Builder setUploadIntervalMs(long uploadIntervalMs) {
+ this.uploadIntervalMs = uploadIntervalMs;
+ return this;
+ }
+
+ Builder setBatchSizeThreshold(int batchSizeThreshold) {
+ this.batchSizeThreshold = batchSizeThreshold;
+ return this;
+ }
+
+ Builder setDriverEnvironment(DriverEnvironment driverEnvironment) {
+ this.driverEnvironment = driverEnvironment;
+ return this;
+ }
+
+ Builder resolveProperties(Properties connectionProperties) {
+ // 1. Connection Properties (lowest precedence)
+ if (connectionProperties != null) {
+ String enableStr = connectionProperties.getProperty("EnableDiagnosticTelemetry");
+ this.enabled = TelemetryPropertyUtility.convertStringToBoolean(enableStr, this.enabled);
+ }
+
+ // 2. Environment Variables (overrides connection properties)
+ try {
+ String envEnabled = System.getenv("BIGQUERY_JDBC_TELEMETRY_ENABLED");
+ this.enabled = TelemetryPropertyUtility.convertStringToBoolean(envEnabled, enabled);
+ } catch (SecurityException e) {
+ // Ignore and proceed
+ }
+
+ // 3. JVM System Properties (highest precedence)
+ try {
+ String sysEnabled = System.getProperty("BIGQUERY_JDBC_TELEMETRY_ENABLED");
+ this.enabled = TelemetryPropertyUtility.convertStringToBoolean(sysEnabled, enabled);
+ } catch (SecurityException e) {
+ // Ignore and proceed
+ }
+
+ return this;
+ }
+
+ TelemetryConfiguration build() {
+ return new TelemetryConfiguration(this);
+ }
+ }
+}
diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManager.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManager.java
new file mode 100644
index 000000000000..cea0b438e5a0
--- /dev/null
+++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManager.java
@@ -0,0 +1,339 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import com.google.cloud.bigquery.BigQueryException;
+import com.google.cloud.bigquery.JobStatistics.QueryStatistics;
+import com.google.cloud.bigquery.jdbc.BigQueryJdbcCustomLogger;
+import com.google.protobuf.Descriptors.EnumValueDescriptor;
+import java.sql.SQLException;
+import java.util.Properties;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Thread-safe singleton hub managing the lifecycle of {@link TelemetryBatcher} for client-side
+ * diagnostic and usage telemetry in the BigQuery JDBC driver.
+ *
+ * All telemetry operations are wrapped in exception safeguards so that failures in metric
+ * collection or batch dispatching never impact standard JDBC functionality.
+ */
+public final class TelemetryManager implements AutoCloseable {
+ private static final Logger logger =
+ new BigQueryJdbcCustomLogger(TelemetryManager.class.getName());
+
+ private static volatile TelemetryManager instance;
+ private static volatile boolean globallyDisabled = false;
+
+ private final TelemetryBatcher batcher;
+
+ private TelemetryManager(TelemetryBatcher batcher) {
+ this.batcher = batcher;
+ }
+
+ /**
+ * Initializes or replaces the shared {@link TelemetryManager} instance with default configuration
+ * and transport.
+ */
+ public static TelemetryManager getInstance() {
+ return getInstance(null);
+ }
+
+ public static TelemetryManager getInstance(Properties properties) {
+ if (globallyDisabled) {
+ return null;
+ }
+
+ if (properties != null
+ && !TelemetryConfiguration.builder().resolveProperties(properties).build().isEnabled()) {
+ synchronized (TelemetryManager.class) {
+ globallyDisabled = true;
+ closeInstance();
+ }
+ return null;
+ }
+
+ TelemetryManager localRef = instance;
+ if (localRef != null) {
+ return localRef;
+ }
+
+ synchronized (TelemetryManager.class) {
+ if (globallyDisabled) {
+ return null;
+ }
+ localRef = instance;
+ if (localRef != null) {
+ return localRef;
+ }
+ TelemetryConfiguration config =
+ TelemetryConfiguration.builder()
+ .resolveProperties(properties)
+ .setDriverEnvironment(DriverEnvironmentDetector.build())
+ .build();
+ ClearcutTransport transport = new ClearcutTransport(config);
+ TelemetryBatcher batcher = new TelemetryBatcher(config, transport);
+ localRef = new TelemetryManager(batcher);
+ // Registered before the instance is published so that a non-null instance always implies a
+ // registered shutdown hook.
+ registerShutdownHook();
+ instance = localRef;
+ return localRef;
+ }
+ }
+
+ /** Package-private lifecycle initialisation method for explicit configuration or unit testing. */
+ static synchronized void init(TelemetryConfiguration config, ClearcutTransport transport) {
+ closeInstance();
+ if (globallyDisabled || config == null || !config.isEnabled() || transport == null) {
+ return;
+ }
+ instance = new TelemetryManager(new TelemetryBatcher(config, transport));
+ }
+
+ /**
+ * Returns the underlying {@link TelemetryBatcher} managed by this instance, or {@code null} if
+ * telemetry is closed or uninitialized.
+ */
+ TelemetryBatcher getBatcher() {
+ return batcher;
+ }
+
+ /**
+ * Returns the {@link TelemetryBatcher} of the active instance, or {@code null} if telemetry is
+ * closed or uninitialized.
+ */
+ private static TelemetryBatcher activeBatcher() {
+ TelemetryManager localRef = instance;
+ return localRef == null ? null : localRef.getBatcher();
+ }
+
+ /**
+ * Executes a telemetry logging operation safely inside an exception-isolated block. Guaranteed to
+ * catch all {@link Throwable} exceptions to protect JDBC driver operations.
+ */
+ public static void runSafely(Runnable action) {
+ if (action == null) {
+ return;
+ }
+ try {
+ action.run();
+ } catch (Throwable t) {
+ logger.log(Level.FINE, "Diagnostic telemetry operation encountered non-fatal error", t);
+ }
+ }
+
+ /** Helper to check if an active instance is present and initialized. */
+ public static boolean isInitialized() {
+ return instance != null;
+ }
+
+ /** Flushes pending buffered metrics and shuts down the shared instance. */
+ public static synchronized void closeInstance() {
+ TelemetryManager localRef = instance;
+ instance = null;
+ if (localRef == null) {
+ return;
+ }
+ try {
+ localRef.close();
+ } catch (Throwable t) {
+ logger.log(Level.FINE, "Error closing TelemetryManager instance", t);
+ }
+ }
+
+ @Override
+ public void close() {
+ batcher.close();
+ }
+
+ // Package-private test helper to reset the global kill switch between test runs
+ static synchronized void resetGlobalDisableForTest() {
+ globallyDisabled = false;
+ }
+
+ public static StatementType toStatementType(QueryStatistics.StatementType bqStatementType) {
+ if (bqStatementType == null) {
+ return StatementType.STATEMENT_TYPE_UNSPECIFIED;
+ }
+
+ EnumValueDescriptor desc =
+ StatementType.getDescriptor().findValueByName("STATEMENT_TYPE_" + bqStatementType.name());
+
+ return desc != null ? StatementType.valueOf(desc) : StatementType.STATEMENT_TYPE_OTHER;
+ }
+
+ public static AuthenticationType toAuthenticationType(int oauthType) {
+ switch (oauthType) {
+ case 0:
+ return AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT;
+ case 1:
+ return AuthenticationType.AUTHENTICATION_TYPE_USER_AUTHENTICATION;
+ case 2:
+ return AuthenticationType.AUTHENTICATION_TYPE_TOKEN;
+ case 3:
+ return AuthenticationType.AUTHENTICATION_TYPE_APPLICATION_DEFAULT_CREDENTIALS;
+ case 4:
+ return AuthenticationType.AUTHENTICATION_TYPE_EXTERNAL;
+ default:
+ return AuthenticationType.AUTHENTICATION_TYPE_CUSTOM;
+ }
+ }
+
+ public static void recordConnectionAttempt(
+ Status status, int errorCode, AuthenticationType authType) {
+ runSafely(
+ () -> {
+ TelemetryBatcher activeBatcher = activeBatcher();
+ if (activeBatcher == null) {
+ return;
+ }
+ activeBatcher.offer(
+ ConnectionAttempt.newBuilder()
+ .setStatus(status)
+ .setErrorCode(errorCode)
+ .setAuthType(authType)
+ .build());
+ });
+ }
+
+ public static void recordStatementExecution(
+ StatementExecution.Builder statementExecutionBuilder, long durationMs) {
+ if (statementExecutionBuilder == null) {
+ return;
+ }
+ runSafely(
+ () -> {
+ TelemetryBatcher activeBatcher = activeBatcher();
+ if (activeBatcher == null) {
+ return;
+ }
+ activeBatcher.offer(statementExecutionBuilder.build(), durationMs);
+ });
+ }
+
+ public static void recordFeatureUsage(DriverFeature feature, String customFeatureName) {
+ runSafely(
+ () -> {
+ TelemetryBatcher activeBatcher = activeBatcher();
+ if (activeBatcher == null) {
+ return;
+ }
+ activeBatcher.offer(
+ FeatureUsage.newBuilder()
+ .setDriverFeature(feature)
+ .setCustomFeatureName(customFeatureName == null ? "" : customFeatureName)
+ .build());
+ });
+ }
+
+ public static void recordFeatureUsage(DriverFeature feature) {
+ recordFeatureUsage(feature, null);
+ }
+
+ public static void recordError(int errorCode, String errorSqlState, String methodName) {
+ runSafely(
+ () -> {
+ TelemetryBatcher activeBatcher = activeBatcher();
+ if (activeBatcher == null) {
+ return;
+ }
+ activeBatcher.offer(
+ ErrorMetric.newBuilder()
+ .setErrorCode(errorCode)
+ // This value is not actively populated, defaults to 0.
+ .setErrorXdbcCode(0)
+ .setErrorSqlState(errorSqlState == null ? "" : errorSqlState)
+ .setMethodName(methodName == null ? "" : methodName)
+ .build());
+ });
+ }
+
+ /**
+ * Extracts the numeric error code from the throwable chain. Traverses causes to unpack
+ * BigQueryException (HTTP status codes) or SQLException error codes. Returns 1000 as the fallback
+ * driver error code.
+ */
+ public static int extractErrorCode(Throwable t) {
+ int depth = 0;
+ while (t != null && depth++ < 20) {
+ if (t instanceof BigQueryException) {
+ int code = ((BigQueryException) t).getCode();
+ if (code != 0) {
+ return code;
+ }
+ }
+ if (t instanceof SQLException) {
+ int code = ((SQLException) t).getErrorCode();
+ if (code != 0) {
+ return code;
+ }
+ }
+ t = t.getCause();
+ }
+ return 1000;
+ }
+
+ /**
+ * Extracts the SQLState from the throwable chain. SQLStates are five-character strings.
+ *
+ *
Returns empty String when no SQLState is present.
+ */
+ public static String extractSqlState(Throwable t) {
+ int depth = 0;
+ while (t != null && depth++ < 20) {
+ if (t instanceof SQLException) {
+ String sqlState = ((SQLException) t).getSQLState();
+ if (sqlState != null && !sqlState.isEmpty()) {
+ return sqlState;
+ }
+ }
+ t = t.getCause();
+ }
+ return "";
+ }
+
+ /**
+ * Registers the JVM shutdown hook that flushes pending telemetry.
+ *
+ *
Only reached from the instance-creation critical section of {@link
+ * #getInstance(Properties)}, which already guarantees single execution, so no further guarding is
+ * needed here.
+ */
+ private static void registerShutdownHook() {
+ try {
+ Runtime.getRuntime()
+ .addShutdownHook(
+ new Thread(
+ TelemetryManager::closeInstanceOnShutdown,
+ "bigquery-jdbc-telemetry-shutdown-hook"));
+ } catch (IllegalStateException e) {
+ // Thrown if the JVM is already in the process of shutting down
+ } catch (SecurityException e) {
+ logger.warning("SecurityManager prevented registering telemetry shutdown hook");
+ }
+ }
+
+ /** Shutdown-hook body: flushes and closes the shared instance without propagating failures. */
+ private static void closeInstanceOnShutdown() {
+ try {
+ closeInstance();
+ } catch (Throwable t) {
+ logger.warning("Error closing TelemetryManager during JVM shutdown");
+ }
+ }
+}
diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryPropertyUtility.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryPropertyUtility.java
new file mode 100644
index 000000000000..1d1294a07ac7
--- /dev/null
+++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryPropertyUtility.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2026 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.
+ */
+
+package com.google.cloud.bigquery.jdbc.telemetry.v1;
+
+import com.google.api.core.InternalApi;
+import com.google.cloud.bigquery.jdbc.BigQueryJdbcCustomLogger;
+
+/**
+ * Utility methods for parsing and validating JDBC connection properties in Telemetry package.
+ *
+ *
This is a subset of parse methods used in the telemetry package. Separated out to keep only
+ * the essential methods.
+ */
+@InternalApi
+public final class TelemetryPropertyUtility {
+ public static final boolean DEFAULT_ENABLE_DIAGNOSTIC_TELEMETRY_VALUE = true;
+ public static final long DEFAULT_TELEMETRY_UPLOAD_INTERVAL_VALUE = 300_000L;
+ public static final int DEFAULT_TELEMETRY_BATCH_SIZE_VALUE = 5000;
+
+ private static final BigQueryJdbcCustomLogger LOG =
+ new BigQueryJdbcCustomLogger(TelemetryPropertyUtility.class.getName());
+
+ private TelemetryPropertyUtility() {
+ // Utility class, static methods only.
+ }
+
+ /**
+ * Converts a String representation of an integer or boolean to a boolean value.
+ *
+ * @param value the string value to parse ("0", "1", "true", or "false")
+ * @param defaultValue default value of teh property
+ * @return true if 1/true, false if 0/false
+ */
+ static boolean convertStringToBoolean(String value, boolean defaultValue) {
+ if (value == null) {
+ return defaultValue;
+ }
+
+ int integerValue;
+
+ try {
+ if (value.equalsIgnoreCase("true")) {
+ integerValue = 1;
+ } else if (value.equalsIgnoreCase("false")) {
+ integerValue = 0;
+ } else {
+ integerValue = Integer.parseInt(value);
+ }
+ } catch (NumberFormatException ex) {
+ // Fail-safe: ignore invalid telemetry values to avoid disrupting JDBC operations.
+ return defaultValue;
+ }
+
+ if (integerValue == 1) {
+ return true;
+ } else if (integerValue == 0) {
+ return false;
+ } else {
+ return defaultValue;
+ }
+ }
+}
diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TransportResult.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TransportResult.java
new file mode 100644
index 000000000000..a6d28508160b
--- /dev/null
+++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TransportResult.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import java.util.Objects;
+
+/** Represents the immutable result of a Clearcut telemetry payload transmission attempt. */
+final class TransportResult {
+ private static final TransportResult DISABLED = new TransportResult(false, -1);
+
+ private final boolean success;
+ private final long nextRequestWaitMillis;
+
+ TransportResult(boolean success, long nextRequestWaitMillis) {
+ this.success = success;
+ this.nextRequestWaitMillis = nextRequestWaitMillis;
+ }
+
+ static TransportResult disabled() {
+ return DISABLED;
+ }
+
+ boolean isSuccess() {
+ return success;
+ }
+
+ long getNextRequestWaitMillis() {
+ return nextRequestWaitMillis;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ TransportResult that = (TransportResult) o;
+ return success == that.success && nextRequestWaitMillis == that.nextRequestWaitMillis;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(success, nextRequestWaitMillis);
+ }
+
+ @Override
+ public String toString() {
+ return "TransportResult{"
+ + "success="
+ + success
+ + ", nextRequestWaitMillis="
+ + nextRequestWaitMillis
+ + '}';
+ }
+}
diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/utils/BigQueryJdbcVersionUtility.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/utils/BigQueryJdbcVersionUtility.java
index ece2ad4bd268..ebab86581f87 100644
--- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/utils/BigQueryJdbcVersionUtility.java
+++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/utils/BigQueryJdbcVersionUtility.java
@@ -80,6 +80,14 @@ public static String getDriverVersion() {
return DRIVER_VERSION;
}
+ /**
+ * Returns a sanitized version of the driver version, containing only the major and minor
+ * components (e.g. "1.2" for "1.2.0-SNAPSHOT").
+ */
+ public static String getSanitizedDriverVersion() {
+ return DRIVER_MAJOR_VERSION + "." + DRIVER_MINOR_VERSION;
+ }
+
public static int getDriverMajorVersion() {
return DRIVER_MAJOR_VERSION;
}
diff --git a/java-bigquery-jdbc/src/main/proto/google/cloud/bigquery/jdbc/telemetry/v1/clientanalytics.proto b/java-bigquery-jdbc/src/main/proto/google/cloud/bigquery/jdbc/telemetry/v1/clientanalytics.proto
new file mode 100644
index 000000000000..9db601f014b7
--- /dev/null
+++ b/java-bigquery-jdbc/src/main/proto/google/cloud/bigquery/jdbc/telemetry/v1/clientanalytics.proto
@@ -0,0 +1,41 @@
+// Copyright 2026 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.
+
+syntax = "proto3";
+
+package google.cloud.bigquery.jdbc.telemetry.v1;
+
+option java_multiple_files = true;
+option java_package = "com.google.cloud.bigquery.jdbc.telemetry.v1";
+option java_outer_classname = "ClientAnalyticsProto";
+
+message LogRequest {
+ ClientInfo client_info = 1;
+ int32 log_source = 2;
+ repeated LogEvent log_events = 3;
+ int64 request_time_ms = 4;
+}
+
+message ClientInfo {
+ int32 client_type = 1;
+}
+
+message LogResponse {
+ int64 next_request_wait_millis = 1;
+}
+
+message LogEvent {
+ int64 event_time_ms = 1;
+ bytes source_extension = 6;
+}
diff --git a/java-bigquery-jdbc/src/main/proto/google/cloud/bigquery/jdbc/telemetry/v1/telemetry.proto b/java-bigquery-jdbc/src/main/proto/google/cloud/bigquery/jdbc/telemetry/v1/telemetry.proto
new file mode 100644
index 000000000000..e469c6490f98
--- /dev/null
+++ b/java-bigquery-jdbc/src/main/proto/google/cloud/bigquery/jdbc/telemetry/v1/telemetry.proto
@@ -0,0 +1,196 @@
+// Copyright 2026 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.
+
+syntax = "proto3";
+
+package google.cloud.bigquery.jdbc.telemetry.v1;
+
+import "google/protobuf/timestamp.proto";
+
+option java_multiple_files = true;
+option java_package = "com.google.cloud.bigquery.jdbc.telemetry.v1";
+option java_outer_classname = "TelemetryProto";
+
+// Aggregated client-side telemetry payload sent to Clearcut analytics backend.
+message TelemetryPayload {
+ DriverEnvironment driver_environment = 1;
+ google.protobuf.Timestamp event_time = 2;
+ repeated ConnectionAttempt connection_attempts = 3;
+ repeated StatementExecution statement_executions = 4;
+ repeated ErrorMetric errors = 5;
+ repeated FeatureUsage feature_usages = 6;
+}
+
+// Client runtime and environment attributes.
+message DriverEnvironment {
+ enum OsType {
+ OS_TYPE_UNSPECIFIED = 0;
+ OS_TYPE_LINUX = 1;
+ OS_TYPE_WINDOWS = 2;
+ OS_TYPE_MACOS = 3;
+ OS_TYPE_SOLARIS = 4;
+ OS_TYPE_FREEBSD = 5;
+ OS_TYPE_OPENBSD = 6;
+ OS_TYPE_NETBSD = 7;
+ OS_TYPE_AIX = 8;
+ OS_TYPE_UNKNOWN = 9;
+ }
+ string driver_name = 1;
+ string driver_version = 2;
+ string client_language = 3;
+ string client_language_version = 4;
+ OsType os_type = 5;
+ string os_version = 6;
+ string telemetry_tag = 7;
+}
+
+// Execution status enum for connection attempts and statement executions.
+enum Status {
+ STATUS_UNSPECIFIED = 0;
+ STATUS_SUCCESS = 1;
+ STATUS_ERROR = 2;
+ STATUS_CANCELLED = 3;
+}
+
+// Authentication types utilized during connection creation.
+enum AuthenticationType {
+ AUTHENTICATION_TYPE_UNSPECIFIED = 0;
+ AUTHENTICATION_TYPE_SERVICE_ACCOUNT = 1;
+ AUTHENTICATION_TYPE_USER_AUTHENTICATION = 2;
+ AUTHENTICATION_TYPE_TOKEN = 3;
+ AUTHENTICATION_TYPE_APPLICATION_DEFAULT_CREDENTIALS = 4;
+ AUTHENTICATION_TYPE_EXTERNAL = 5;
+ AUTHENTICATION_TYPE_CUSTOM = 6;
+}
+
+// Aggregated metrics for connection attempts.
+message ConnectionAttempt {
+ Status status = 1;
+ int32 error_code = 2;
+ AuthenticationType auth_type = 3;
+ int64 count = 4;
+}
+
+// High-level classification of statement execution types.
+enum StatementType {
+ STATEMENT_TYPE_UNSPECIFIED = 0;
+ STATEMENT_TYPE_SELECT = 1;
+ STATEMENT_TYPE_INSERT = 2;
+ STATEMENT_TYPE_UPDATE = 3;
+ STATEMENT_TYPE_DELETE = 4;
+ STATEMENT_TYPE_CREATE_EXTERNAL_TABLE = 5;
+ STATEMENT_TYPE_CREATE_FUNCTION = 6;
+ STATEMENT_TYPE_CREATE_MATERIALIZED_VIEW = 7;
+ STATEMENT_TYPE_CREATE_MODEL = 8;
+ STATEMENT_TYPE_CREATE_PROCEDURE = 9;
+ STATEMENT_TYPE_CREATE_ROW_ACCESS_POLICY = 10;
+ STATEMENT_TYPE_CREATE_SCHEMA = 11;
+ STATEMENT_TYPE_CREATE_SEARCH_INDEX = 12;
+ STATEMENT_TYPE_CREATE_SNAPSHOT_TABLE = 13;
+ STATEMENT_TYPE_CREATE_TABLE = 14;
+ STATEMENT_TYPE_CREATE_TABLE_AS_SELECT = 15;
+ STATEMENT_TYPE_CREATE_TABLE_FUNCTION = 16;
+ STATEMENT_TYPE_CREATE_VIEW = 17;
+ STATEMENT_TYPE_DROP_EXTERNAL_TABLE = 18;
+ STATEMENT_TYPE_DROP_FUNCTION = 19;
+ STATEMENT_TYPE_DROP_MATERIALIZED_VIEW = 20;
+ STATEMENT_TYPE_DROP_MODEL = 21;
+ STATEMENT_TYPE_DROP_PROCEDURE = 22;
+ STATEMENT_TYPE_DROP_ROW_ACCESS_POLICY = 23;
+ STATEMENT_TYPE_DROP_SCHEMA = 24;
+ STATEMENT_TYPE_DROP_SEARCH_INDEX = 25;
+ STATEMENT_TYPE_DROP_SNAPSHOT_TABLE = 26;
+ STATEMENT_TYPE_DROP_TABLE = 27;
+ STATEMENT_TYPE_DROP_TABLE_FUNCTION = 28;
+ STATEMENT_TYPE_DROP_VIEW = 29;
+ STATEMENT_TYPE_ALTER_MATERIALIZED_VIEW = 30;
+ STATEMENT_TYPE_ALTER_SCHEMA = 31;
+ STATEMENT_TYPE_ALTER_TABLE = 32;
+ STATEMENT_TYPE_ALTER_VIEW = 33;
+ STATEMENT_TYPE_CALL = 34;
+ STATEMENT_TYPE_EXPLAIN = 35;
+ STATEMENT_TYPE_EXPORT_DATA = 36;
+ STATEMENT_TYPE_EXPORT_MODEL = 37;
+ STATEMENT_TYPE_LOAD_DATA = 38;
+ STATEMENT_TYPE_MERGE = 39;
+ STATEMENT_TYPE_SCRIPT = 40;
+ STATEMENT_TYPE_TRUNCATE_TABLE = 41;
+ STATEMENT_TYPE_OTHER = 42;
+}
+
+// API substrate used during query execution (REST API vs Read API vs Write API vs Jobless Query).
+enum QueryApiType {
+ QUERY_API_TYPE_UNSPECIFIED = 0;
+ QUERY_API_TYPE_STANDARD_REST_API = 1;
+ QUERY_API_TYPE_READ_API = 2;
+ QUERY_API_TYPE_WRITE_API = 3;
+ QUERY_API_TYPE_JOBLESS_QUERY = 4;
+}
+
+// Aggregated execution metrics for statement executions.
+message StatementExecution {
+ StatementType statement_type = 1;
+ QueryApiType query_api_type = 2;
+ Status status = 3;
+ int32 error_code = 4;
+ int64 count = 5;
+ DurationHistogram duration = 6;
+}
+
+// Histogram representation for query duration measurements in milliseconds.
+message DurationHistogram {
+ // Count of items in each bucket.
+ repeated int64 bucket_counts = 1;
+
+ // Upper bounds for each bucket in milliseconds.
+ repeated double explicit_bounds = 2;
+
+ // Total count of recorded duration events.
+ int64 count = 3;
+
+ // Cumulative sum of recorded durations in milliseconds.
+ double sum = 4;
+}
+
+// Aggregated error counts grouped by error code, SQL state, and method.
+message ErrorMetric {
+ int32 error_code = 1;
+ int32 error_xdbc_code = 2;
+ string method_name = 3;
+ int64 count = 4;
+ string error_sql_state = 5;
+}
+
+// High-level driver runtime features and capabilities tracked for telemetry.
+enum DriverFeature {
+ DRIVER_FEATURE_UNSPECIFIED = 0;
+ DRIVER_FEATURE_BATCH_OPERATIONS = 1;
+ DRIVER_FEATURE_PREPARED_STATEMENT = 2;
+ DRIVER_FEATURE_CALLABLE_STATEMENT = 3;
+ DRIVER_FEATURE_AUTOCOMMIT_ENABLED = 4;
+ DRIVER_FEATURE_AUTOCOMMIT_DISABLED = 5;
+ DRIVER_FEATURE_TRANSACTIONS = 6;
+ DRIVER_FEATURE_CONNECTION_POOLING = 7;
+ DRIVER_FEATURE_STATEMENT_CANCEL = 8;
+ DRIVER_FEATURE_PARAMETER_BINDING = 9;
+ DRIVER_FEATURE_METADATA_RETRIEVAL = 10;
+ DRIVER_FEATURE_CUSTOM = 11;
+}
+
+// Usage metrics for specific features.
+message FeatureUsage {
+ DriverFeature driver_feature = 1;
+ string custom_feature_name = 2;
+ int64 count = 3;
+}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java
index f13ace2f140f..7e051c2275ea 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java
@@ -40,9 +40,6 @@ static Stream exceptionProvider() {
Arguments.of((ExceptionCreator) BigQueryJdbcException::new),
Arguments.of((ExceptionCreator) BigQueryJdbcRuntimeException::new),
Arguments.of((ExceptionCreator) BigQueryConversionException::new),
- Arguments.of(
- (ExceptionCreator)
- (msg, cause) -> new BigQueryJdbcCoercionException((Exception) cause)),
Arguments.of(
(ExceptionCreator)
(msg, cause) ->
@@ -57,9 +54,7 @@ public void testExceptionMessageFormatting(ExceptionCreator creator) {
Throwable ex = creator.create(message, cause);
- String expectedPrefix =
- ex instanceof BigQueryJdbcCoercionException ? "Coercion error" : message;
- String expectedMessage = expectedPrefix + "\n" + cause.getMessage();
+ String expectedMessage = message + "\n" + cause.getMessage();
assertEquals(expectedMessage, ex.getMessage());
assertEquals(cause, ex.getCause());
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java
index c9b71da98fe9..6bec8d3df66b 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java
@@ -472,4 +472,30 @@ public void testArrowArrayTimestampPicosDisabled() throws SQLException {
assertThat(rs.getString(2)).isEqualTo("2026-04-08 10:00:00.123456");
assertThat(rs.getObject(2)).isEqualTo(expectedTs);
}
+
+ @Test
+ public void testArrowArrayTimePreservesMicroseconds() throws SQLException {
+ long t1Micros = (14L * 3600 + 30L * 60) * 1_000_000L + 123_456L;
+ long t2Micros = (23L * 3600 + 59L * 60 + 59L) * 1_000_000L + 999_999L;
+ long t3Micros = 3600L * 1_000_000L; // 01:00:00
+ Tuple> schemaAndValue =
+ arrowArraySchemaAndValue(TIME, t1Micros, t2Micros, t3Micros);
+ BigQueryArrowArray array = new BigQueryArrowArray(schemaAndValue.x(), schemaAndValue.y());
+
+ assertThat(array.toString()).isEqualTo("[14:30:00.123456, 23:59:59.999999, 01:00:00]");
+
+ try (ResultSet rs = array.getResultSet()) {
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString(2)).isEqualTo("14:30:00.123456");
+ assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(14, 30, 0, 123_456_000));
+
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString(2)).isEqualTo("23:59:59.999999");
+ assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(23, 59, 59, 999_999_000));
+
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString(2)).isEqualTo("01:00:00");
+ assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(1, 0, 0));
+ }
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java
index 8681fd9559a5..6f8a249513b1 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java
@@ -24,6 +24,8 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import com.google.cloud.bigquery.Field;
import com.google.cloud.bigquery.Field.Mode;
@@ -339,6 +341,36 @@ public void testIsClosed() {
assertThat(bigQueryArrowResultSet.isClosed()).isFalse();
}
+ @Test
+ public void testCloseCancelsProducerClearsBufferAndIsIdempotent()
+ throws SQLException, IOException {
+ ArrowSchema arrowSchema =
+ ArrowSchema.newBuilder()
+ .setSerializedSchema(serializeSchema(vectorSchemaRoot.getSchema()))
+ .build();
+ Future> workerTask = mock(Future.class);
+ BlockingQueue queue = new LinkedBlockingDeque<>(4);
+ queue.add(arrowBatchWrapper);
+ queue.add(arrowBatchWrapperLast);
+
+ BigQueryArrowResultSet rs =
+ BigQueryArrowResultSet.of(QUERY_SCHEMA, arrowSchema, 1, statement, queue, workerTask, null);
+
+ assertThat(rs.isClosed()).isFalse();
+ assertThat(queue).hasSize(2);
+
+ rs.close();
+ assertThat(rs.isClosed()).isTrue();
+ assertThat(queue).hasSize(1);
+ assertThat(queue.peek().getCurrentArrowBatch()).isNull();
+ assertThat(queue.peek().isLast()).isTrue();
+ verify(workerTask, times(1)).cancel(true);
+
+ // Second close() call should be a no-op
+ rs.close();
+ verify(workerTask, times(1)).cancel(true);
+ }
+
@Test
public void testResultSetHoldability() throws SQLException {
assertThat(bigQueryArrowResultSet.getHoldability())
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java
index e63bfe79b397..46592e20397f 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java
@@ -181,6 +181,20 @@ GEOGRAPHY, new Text("POINT(-122 47)"), new Text("POINT(-122 48)")),
.isEqualTo(new String[] {"POINT(-122 47)", "POINT(-122 48)"});
assertThat(((Array) attributes[11]).getArray())
.isEqualTo(new byte[][] {"one".getBytes(), "two".getBytes()});
+ assertThat(struct.toString())
+ .isEqualTo(
+ "{\"INT64_arr_column\":[10,20],"
+ + "\"BOOL_arr_column\":[true,false],"
+ + "\"FLOAT64_arr_column\":[11.2,33.4],"
+ + "\"NUMERIC_arr_column\":[11.2657,33.4657],"
+ + "\"BIGNUMERIC_arr_column\":[11.2657,33.4657],"
+ + "\"STRING_arr_column\":[\"one\",\"two\"],"
+ + "\"TIMESTAMP_arr_column\":[\"2023-03-30 11:14:19.820227\",\"2023-03-31 11:14:19.820227\"],"
+ + "\"DATE_arr_column\":[\"2023-03-30\",\"2023-03-31\"],"
+ + "\"TIME_arr_column\":[\"11:14:19.820227\",\"11:14:20.820227\"],"
+ + "\"DATETIME_arr_column\":[\"2023-03-30 11:14:19.820227\",\"2023-03-30 11:15:19.820227\"],"
+ + "\"GEOGRAPHY_arr_column\":[\"POINT(-122 47)\",\"POINT(-122 48)\"],"
+ + "\"BYTES_arr_column\":[\"b25l\",\"dHdv\"]}");
}
@Test
@@ -278,4 +292,17 @@ public void testArrowStructTimestampPicosDisabled() throws SQLException {
Timestamp expectedTs = Timestamp.valueOf("2026-04-08 10:00:00.123456789");
assertThat(attributes).isEqualTo(new Object[] {expectedTs});
}
+
+ @Test
+ public void testArrowStructTimePreservesMicroseconds() {
+ Field timeField = Field.of("t", TIME);
+ FieldList schema = FieldList.of(timeField);
+ long timeMicros = (14L * 3600 + 30L * 60) * 1_000_000L + 123_456L;
+ JsonStringHashMap values = new JsonStringHashMap<>();
+ values.put("t", timeMicros);
+
+ BigQueryArrowStruct struct = new BigQueryArrowStruct(schema, values);
+
+ assertThat(struct.toString()).isEqualTo("{\"t\":\"14:30:00.123456\"}");
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDaemonPollingTaskTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDaemonPollingTaskTest.java
index 29941152e7c5..66c1c0b50c7e 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDaemonPollingTaskTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDaemonPollingTaskTest.java
@@ -17,11 +17,17 @@
package com.google.cloud.bigquery.jdbc;
import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
import com.google.cloud.bigquery.jdbc.BigQueryResultSetFinalizers.ArrowResultSetFinalizer;
+import com.google.cloud.bigquery.jdbc.BigQueryResultSetFinalizers.JsonResultSetFinalizer;
import java.lang.ref.ReferenceQueue;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.concurrent.Future;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -36,8 +42,8 @@ public class BigQueryDaemonPollingTaskTest {
public void setUp() {
referenceQueueArrowRs = new ReferenceQueue<>();
referenceQueueJsonRs = new ReferenceQueue<>();
- arrowResultSetFinalizers = new ArrayList<>();
- jsonResultSetFinalizers = new ArrayList<>();
+ arrowResultSetFinalizers = Collections.synchronizedList(new ArrayList<>());
+ jsonResultSetFinalizers = Collections.synchronizedList(new ArrayList<>());
}
@Test
@@ -60,4 +66,83 @@ public void testStartGcDaemonTask() {
jsonResultSetFinalizers))
.isFalse();
}
+
+ @Test
+ public void testJsonDaemonFinalizesAndReleasesReference() throws Exception {
+ Future> worker1 = mock(Future.class);
+ Future> worker2 = mock(Future.class);
+ JsonResultSetFinalizer finalizer =
+ new JsonResultSetFinalizer(null, referenceQueueJsonRs, new Future>[] {worker1, worker2});
+ jsonResultSetFinalizers.add(finalizer);
+ BigQueryDaemonPollingTask daemon =
+ new BigQueryDaemonPollingTask(
+ "test-json-daemon", referenceQueueJsonRs, jsonResultSetFinalizers);
+ daemon.start();
+ try {
+ finalizer.enqueue(); // simulate the GC enqueuing the phantom reference
+ verify(worker1, timeout(5000)).cancel(true);
+ verify(worker2, timeout(5000)).cancel(true);
+ awaitEmpty(jsonResultSetFinalizers);
+ } finally {
+ daemon.interrupt();
+ }
+ }
+
+ @Test
+ public void testArrowDaemonFinalizesAndReleasesReference() throws Exception {
+ Future> worker = mock(Future.class);
+ ArrowResultSetFinalizer finalizer =
+ new ArrowResultSetFinalizer(null, referenceQueueArrowRs, worker);
+ arrowResultSetFinalizers.add(finalizer);
+ BigQueryDaemonPollingTask daemon =
+ new BigQueryDaemonPollingTask(
+ "test-arrow-daemon", referenceQueueArrowRs, arrowResultSetFinalizers);
+ daemon.start();
+ try {
+ finalizer.enqueue();
+ verify(worker, timeout(5000)).cancel(true);
+ awaitEmpty(arrowResultSetFinalizers);
+ } finally {
+ daemon.interrupt();
+ }
+ }
+
+ @Test
+ public void testEachDaemonPollsOnlyItsOwnQueue() throws Exception {
+ Future> jsonWorker = mock(Future.class);
+ Future> arrowWorker = mock(Future.class);
+ JsonResultSetFinalizer jsonFinalizer =
+ new JsonResultSetFinalizer(null, referenceQueueJsonRs, new Future>[] {jsonWorker});
+ ArrowResultSetFinalizer arrowFinalizer =
+ new ArrowResultSetFinalizer(null, referenceQueueArrowRs, arrowWorker);
+ jsonResultSetFinalizers.add(jsonFinalizer);
+ arrowResultSetFinalizers.add(arrowFinalizer);
+ BigQueryDaemonPollingTask jsonDaemon =
+ new BigQueryDaemonPollingTask("test-json", referenceQueueJsonRs, jsonResultSetFinalizers);
+ BigQueryDaemonPollingTask arrowDaemon =
+ new BigQueryDaemonPollingTask(
+ "test-arrow", referenceQueueArrowRs, arrowResultSetFinalizers);
+ jsonDaemon.start();
+ arrowDaemon.start();
+ try {
+ jsonFinalizer.enqueue();
+ arrowFinalizer.enqueue();
+ verify(jsonWorker, timeout(5000)).cancel(true);
+ verify(arrowWorker, timeout(5000)).cancel(true);
+ awaitEmpty(jsonResultSetFinalizers);
+ awaitEmpty(arrowResultSetFinalizers);
+ } finally {
+ jsonDaemon.interrupt();
+ arrowDaemon.interrupt();
+ }
+ }
+
+ /** Small poll loop; the list is drained by the daemon thread shortly after cancel() runs. */
+ private static void awaitEmpty(List> list) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 5000;
+ while (!list.isEmpty() && System.currentTimeMillis() < deadline) {
+ Thread.sleep(20);
+ }
+ assertThat(list).isEmpty();
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDriverTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDriverTest.java
index 8acbc5abb8dc..4c4030d3e1f8 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDriverTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryDriverTest.java
@@ -18,6 +18,8 @@
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.mock;
+import com.google.cloud.bigquery.jdbc.telemetry.v1.TelemetryManager;
+import com.google.cloud.bigquery.jdbc.telemetry.v1.TelemetryTestUtils;
import com.google.cloud.bigquery.jdbc.utils.BigQueryJdbcVersionUtility;
import io.opentelemetry.api.OpenTelemetry;
import java.sql.Connection;
@@ -26,6 +28,7 @@
import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -34,6 +37,12 @@ public class BigQueryDriverTest extends BigQueryJdbcLoggingBaseTest {
static BigQueryDriver bigQueryDriver;
+ @BeforeEach
+ @AfterEach
+ public void resetTelemetry() {
+ TelemetryTestUtils.reset();
+ }
+
@BeforeEach
public void setUp() {
bigQueryDriver = BigQueryDriver.getRegisteredDriver();
@@ -187,4 +196,45 @@ public void testInvalidLogLevelExceptionIsLogged() {
&& r.getMessage().contains("Failed to parse connection URL properties"));
assertThat(foundSevere).isTrue();
}
+
+ @Test
+ public void testConnect_recordsSuccessfulConnectionTelemetry() throws SQLException {
+ Connection connection =
+ bigQueryDriver.connect(
+ "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;"
+ + "OAuthType=2;ProjectId=MyBigQueryProject;"
+ + "OAuthAccessToken=redactedToken;OAuthClientId=redactedToken;"
+ + "OAuthClientSecret=redactedToken;",
+ new Properties());
+ assertThat(connection).isNotNull();
+ assertThat(connection.isClosed()).isFalse();
+ // Verify TelemetryManager is initialized and recorded the connection
+ assertThat(TelemetryManager.isInitialized()).isTrue();
+ }
+
+ @Test
+ public void testConnect_recordsFailedConnectionTelemetry() {
+ // Malformed URL causing DataSource parsing failure
+ Assertions.assertThrows(
+ SQLException.class,
+ () ->
+ bigQueryDriver.connect(
+ "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;OAuthType=invalid;",
+ new Properties()));
+ assertThat(TelemetryManager.isInitialized()).isTrue();
+ }
+
+ @Test
+ public void testConnect_optOut_noTelemetryRecorded() throws SQLException {
+ Connection connection =
+ bigQueryDriver.connect(
+ "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;"
+ + "OAuthType=2;ProjectId=MyBigQueryProject;"
+ + "OAuthAccessToken=redactedToken;OAuthClientId=redactedToken;"
+ + "OAuthClientSecret=redactedToken;EnableDiagnosticTelemetry=0;",
+ new Properties());
+ assertThat(connection).isNotNull();
+ // Since opt-out was requested, TelemetryManager should NOT be initialized
+ assertThat(TelemetryManager.isInitialized()).isFalse();
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java
index eb0e06390b09..711c585d460a 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java
@@ -20,6 +20,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doReturn;
@@ -127,4 +128,16 @@ public void testWrapWithNullContextAndExceptionThrown() throws SQLException {
assertEquals("Database error", ex.getMessage());
assertNull(BigQueryJdbcMdc.getConnectionId());
}
+
+ @Test
+ public void testInvoke_rethrowsCauseUnchangedAfterTelemetry() throws Exception {
+ Statement failing = mock(Statement.class);
+ SQLException expected = new SQLException("bad syntax", "42000", 404);
+ when(failing.execute("SELECT 1")).thenThrow(expected);
+ Statement proxy = BigQueryJdbcContextProxy.wrap(failing, Statement.class, "conn-1");
+ SQLException actual = assertThrows(SQLException.class, () -> proxy.execute("SELECT 1"));
+ assertSame(expected, actual);
+ assertEquals("42000", actual.getSQLState());
+ assertEquals(404, actual.getErrorCode());
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcCustomLoggerTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcCustomLoggerTest.java
index 38a5df969406..c4a6d576f91c 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcCustomLoggerTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcCustomLoggerTest.java
@@ -20,6 +20,8 @@
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
import com.google.cloud.bigquery.Field;
import com.google.cloud.bigquery.FieldList;
@@ -37,6 +39,7 @@
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
+import java.util.logging.Logger;
import org.apache.arrow.vector.util.JsonStringArrayList;
import org.apache.arrow.vector.util.JsonStringHashMap;
import org.junit.jupiter.api.AfterEach;
@@ -49,6 +52,8 @@ public class BigQueryJdbcCustomLoggerTest extends BigQueryJdbcLoggingBaseTest {
private BigQueryJdbcCustomLogger logger;
private TestHandler testHandler;
+ private static final int OBJECT_COUNT = 1_000;
+
private static class TestHandler extends Handler {
private final List records = new ArrayList<>();
@@ -192,53 +197,60 @@ public void testLogWithException() {
@Test
public void testResultSetLoggerTrace() {
BigQueryJdbcResultSetLogger rsLogger =
- new BigQueryJdbcResultSetLogger(BigQueryBaseResultSet.class);
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class);
TestHandler rsHandler = new TestHandler();
- rsLogger.addHandler(rsHandler);
- rsLogger.setLevel(Level.ALL);
+ rsLogger.delegate().addHandler(rsHandler);
+ rsLogger.delegate().setLevel(Level.ALL);
+ try {
- rsLogger.finestTrace("customMethod", "Hello finest trace message");
+ rsLogger.finestTrace("customMethod", "Hello finest trace message");
- List records = rsHandler.getRecords();
- assertEquals(1, records.size());
- LogRecord record = records.get(0);
+ List records = rsHandler.getRecords();
+ assertEquals(1, records.size());
+ LogRecord record = records.get(0);
- assertEquals(Level.FINEST, record.getLevel());
- assertEquals("customMethod", record.getSourceMethodName());
- assertEquals(BigQueryBaseResultSet.class.getName(), record.getSourceClassName());
- assertEquals("Hello finest trace message", record.getMessage());
+ assertEquals(Level.FINEST, record.getLevel());
+ assertEquals("customMethod", record.getSourceMethodName());
+ assertEquals(BigQueryBaseResultSet.class.getName(), record.getSourceClassName());
+ assertEquals("Hello finest trace message", record.getMessage());
+ } finally {
+ rsLogger.delegate().removeHandler(rsHandler);
+ }
}
@Test
public void testResultSetLoggerStandardMethods() {
BigQueryJdbcResultSetLogger rsLogger =
- new BigQueryJdbcResultSetLogger(BigQueryBaseResultSet.class);
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class);
TestHandler rsHandler = new TestHandler();
- rsLogger.addHandler(rsHandler);
- rsLogger.setLevel(Level.ALL);
-
- rsLogger.finest("Finest msg");
- rsLogger.finer("Finer msg");
- rsLogger.fine("Fine msg");
-
- List records = rsHandler.getRecords();
- assertEquals(3, records.size());
-
- LogRecord r1 = records.get(0);
- assertEquals(Level.FINEST, r1.getLevel());
- assertEquals("unknown", r1.getSourceMethodName());
- assertEquals(BigQueryBaseResultSet.class.getName(), r1.getSourceClassName());
- assertEquals("Finest msg", r1.getMessage());
-
- LogRecord r2 = records.get(1);
- assertEquals(Level.FINER, r2.getLevel());
- assertEquals("unknown", r2.getSourceMethodName());
- assertEquals("Finer msg", r2.getMessage());
-
- LogRecord r3 = records.get(2);
- assertEquals(Level.FINE, r3.getLevel());
- assertEquals("unknown", r3.getSourceMethodName());
- assertEquals("Fine msg", r3.getMessage());
+ rsLogger.delegate().addHandler(rsHandler);
+ rsLogger.delegate().setLevel(Level.ALL);
+ try {
+ rsLogger.finest("Finest msg");
+ rsLogger.finer("Finer msg");
+ rsLogger.fine("Fine msg");
+
+ List records = rsHandler.getRecords();
+ assertEquals(3, records.size());
+
+ LogRecord r1 = records.get(0);
+ assertEquals(Level.FINEST, r1.getLevel());
+ assertEquals("unknown", r1.getSourceMethodName());
+ assertEquals(BigQueryBaseResultSet.class.getName(), r1.getSourceClassName());
+ assertEquals("Finest msg", r1.getMessage());
+
+ LogRecord r2 = records.get(1);
+ assertEquals(Level.FINER, r2.getLevel());
+ assertEquals("unknown", r2.getSourceMethodName());
+ assertEquals("Finer msg", r2.getMessage());
+
+ LogRecord r3 = records.get(2);
+ assertEquals(Level.FINE, r3.getLevel());
+ assertEquals("unknown", r3.getSourceMethodName());
+ assertEquals("Fine msg", r3.getMessage());
+ } finally {
+ rsLogger.delegate().removeHandler(rsHandler);
+ }
}
@Test
@@ -258,21 +270,24 @@ public void testCustomLoggerSupplierMethods() {
@Test
public void testResultSetLoggerSupplierMethods() {
BigQueryJdbcResultSetLogger rsLogger =
- new BigQueryJdbcResultSetLogger(BigQueryBaseResultSet.class);
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class);
TestHandler rsHandler = new TestHandler();
- rsLogger.addHandler(rsHandler);
- rsLogger.setLevel(Level.ALL);
-
- rsLogger.finest(() -> "Lazy finest RS message");
+ rsLogger.delegate().addHandler(rsHandler);
+ rsLogger.delegate().setLevel(Level.ALL);
+ try {
+ rsLogger.finest(() -> "Lazy finest RS message");
- List records = rsHandler.getRecords();
- assertEquals(1, records.size());
- LogRecord record = records.get(0);
+ List records = rsHandler.getRecords();
+ assertEquals(1, records.size());
+ LogRecord record = records.get(0);
- assertEquals(Level.FINEST, record.getLevel());
- assertEquals("unknown", record.getSourceMethodName());
- assertEquals(BigQueryBaseResultSet.class.getName(), record.getSourceClassName());
- assertEquals("Lazy finest RS message", record.getMessage());
+ assertEquals(Level.FINEST, record.getLevel());
+ assertEquals("unknown", record.getSourceMethodName());
+ assertEquals(BigQueryBaseResultSet.class.getName(), record.getSourceClassName());
+ assertEquals("Lazy finest RS message", record.getMessage());
+ } finally {
+ rsLogger.delegate().removeHandler(rsHandler);
+ }
}
@Test
@@ -293,60 +308,70 @@ public void testSupplierNotEvaluatedWhenDisabled() {
@Test
public void testResultSetLoggerSupplierTraceMethods() {
BigQueryJdbcResultSetLogger rsLogger =
- new BigQueryJdbcResultSetLogger(BigQueryBaseResultSet.class);
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class);
TestHandler rsHandler = new TestHandler();
- rsLogger.addHandler(rsHandler);
- rsLogger.setLevel(Level.ALL);
-
- rsLogger.finestTrace("customTraceMethod", () -> "Lazy finest RS trace message");
+ rsLogger.delegate().addHandler(rsHandler);
+ rsLogger.delegate().setLevel(Level.ALL);
+ try {
+ rsLogger.finestTrace("customTraceMethod", () -> "Lazy finest RS trace message");
- List records = rsHandler.getRecords();
- assertEquals(1, records.size());
- LogRecord record = records.get(0);
+ List records = rsHandler.getRecords();
+ assertEquals(1, records.size());
+ LogRecord record = records.get(0);
- assertEquals(Level.FINEST, record.getLevel());
- assertEquals("customTraceMethod", record.getSourceMethodName());
- assertEquals(BigQueryBaseResultSet.class.getName(), record.getSourceClassName());
- assertEquals("Lazy finest RS trace message", record.getMessage());
+ assertEquals(Level.FINEST, record.getLevel());
+ assertEquals("customTraceMethod", record.getSourceMethodName());
+ assertEquals(BigQueryBaseResultSet.class.getName(), record.getSourceClassName());
+ assertEquals("Lazy finest RS trace message", record.getMessage());
+ } finally {
+ rsLogger.delegate().removeHandler(rsHandler);
+ }
}
@Test
public void testResultSetLoggerCachingBehavior() {
- // Global logger (connectionId is null) should be cached
+ // Global facades (no connection ID) are shared.
BigQueryJdbcResultSetLogger globalLogger1 =
BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class);
BigQueryJdbcResultSetLogger globalLogger2 =
BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class);
assertSame(globalLogger1, globalLogger2);
-
- // Connection-specific loggers should not be cached globally
+ // Connection-scoped facades are distinct lightweight objects, but they all forward to the
+ // single registered logger for the class — that is what prevents the registration leak.
BigQueryJdbcResultSetLogger connLogger1 =
BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class, "conn-123");
BigQueryJdbcResultSetLogger connLogger2 =
- BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class, "conn-123");
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class, "conn-456");
assertNotSame(connLogger1, connLogger2);
+ assertSame(connLogger1.delegate(), connLogger2.delegate());
+ assertSame(globalLogger1.delegate(), connLogger1.delegate());
}
@Test
public void testResultSetLoggerParameterPreservation() {
BigQueryJdbcResultSetLogger rsLogger =
- new BigQueryJdbcResultSetLogger(BigQueryBaseResultSet.class, "conn-123");
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryBaseResultSet.class, "conn-123");
TestHandler rsHandler = new TestHandler();
- rsLogger.addHandler(rsHandler);
- rsLogger.setLevel(Level.ALL);
+ rsLogger.delegate().addHandler(rsHandler);
+ rsLogger.delegate().setLevel(Level.ALL);
- LogRecord record = new LogRecord(Level.INFO, "Processing job {0}");
- record.setParameters(new Object[] {"job-999"});
- rsLogger.log(record);
+ try {
- List records = rsHandler.getRecords();
- assertEquals(1, records.size());
- LogRecord loggedRecord = records.get(0);
+ LogRecord record = new LogRecord(Level.INFO, "Processing job {0}");
+ record.setParameters(new Object[] {"job-999"});
+ rsLogger.log(record);
- Object[] params = loggedRecord.getParameters();
- assertEquals(2, params.length);
- assertEquals("conn-123", params[0]);
- assertEquals("job-999", params[1]);
+ List records = rsHandler.getRecords();
+ assertEquals(1, records.size());
+ LogRecord loggedRecord = records.get(0);
+
+ Object[] params = loggedRecord.getParameters();
+ assertEquals(2, params.length);
+ assertEquals("conn-123", params[0]);
+ assertEquals("job-999", params[1]);
+ } finally {
+ rsLogger.delegate().removeHandler(rsHandler);
+ }
}
private void assertConnectionIdPropagated(
@@ -355,7 +380,7 @@ private void assertConnectionIdPropagated(
String expectedMessage,
Runnable action) {
Level originalRootLevel = BigQueryJdbcRootLogger.getRootLogger().getLevel();
- Level originalLoggerLevel = logger.getLevel();
+ Level originalLoggerLevel = logger.delegate().getLevel();
Map originalHandlerLevels = new HashMap<>();
for (Handler h : BigQueryJdbcRootLogger.getRootLogger().getHandlers()) {
originalHandlerLevels.put(h, h.getLevel());
@@ -363,7 +388,7 @@ private void assertConnectionIdPropagated(
}
BigQueryJdbcRootLogger.getRootLogger().setLevel(Level.ALL);
- logger.setLevel(Level.ALL);
+ logger.delegate().setLevel(Level.ALL);
try {
capturedLogs.clear();
@@ -373,7 +398,7 @@ private void assertConnectionIdPropagated(
assertTrue(assertLogContains(expectedMessage));
} finally {
BigQueryJdbcRootLogger.getRootLogger().setLevel(originalRootLevel);
- logger.setLevel(originalLoggerLevel);
+ logger.delegate().setLevel(originalLoggerLevel);
for (Entry entry : originalHandlerLevels.entrySet()) {
entry.getKey().setLevel(entry.getValue());
}
@@ -579,4 +604,141 @@ public void testNestedArrayInJsonStructConnectionIdPropagation() throws Exceptio
"Log from Nested JSON Array",
() -> nestedArray.LOG.fine("Log from Nested JSON Array"));
}
+
+ @Test
+ public void testLoggerIsParentedToDriverRootLogger() {
+ assertSame(
+ BigQueryJdbcRootLogger.getRootLogger(),
+ new BigQueryJdbcCustomLogger("ParentCheck").getParent());
+ }
+
+ @Test
+ public void testCreatingStatementsDoesNotRegisterChildLoggers() throws Exception {
+ BigQueryConnection connection = mock(BigQueryConnection.class);
+ doReturn("leak-test-connection").when(connection).getConnectionId();
+ doReturn(BigQueryConnection.SessionState.empty()).when(connection).getSessionStateSnapshot();
+
+ Logger rootLogger = BigQueryJdbcRootLogger.getRootLogger();
+ // Warm up so that any one-time, class-level logger is registered before we measure.
+ new BigQueryStatement(connection);
+ int childrenBefore = childLoggerCount(rootLogger);
+
+ for (int i = 0; i < OBJECT_COUNT; i++) {
+ new BigQueryStatement(connection);
+ }
+
+ assertEquals(
+ childrenBefore,
+ childLoggerCount(rootLogger),
+ "Creating "
+ + OBJECT_COUNT
+ + " statements must not register additional child loggers on the driver root logger");
+ }
+
+ @Test
+ public void testCreatingPreparedAndCallableStatementsDoesNotRegisterChildLoggers()
+ throws Exception {
+ BigQueryConnection connection = mock(BigQueryConnection.class);
+ doReturn("leak-test-connection").when(connection).getConnectionId();
+ doReturn(BigQueryConnection.SessionState.empty()).when(connection).getSessionStateSnapshot();
+
+ Logger rootLogger = BigQueryJdbcRootLogger.getRootLogger();
+ // Warm up so that any one-time, class-level logger is registered before we measure.
+ new BigQueryPreparedStatement(connection, "SELECT 1");
+ new BigQueryCallableStatement(connection, "CALL my_proc()");
+ int childrenBefore = childLoggerCount(rootLogger);
+
+ for (int i = 0; i < OBJECT_COUNT; i++) {
+ new BigQueryPreparedStatement(connection, "SELECT 1");
+ new BigQueryCallableStatement(connection, "CALL my_proc()");
+ }
+
+ assertEquals(
+ childrenBefore,
+ childLoggerCount(rootLogger),
+ "Creating "
+ + OBJECT_COUNT
+ + " PreparedStatements and CallableStatements must not register additional child"
+ + " loggers on the driver root logger");
+ }
+
+ @Test
+ public void testCreatingParameterHandlersAndBulkInsertWritersDoesNotRegisterChildLoggers()
+ throws ReflectiveOperationException {
+ Logger rootLogger = BigQueryJdbcRootLogger.getRootLogger();
+ // Warm up so that any one-time, class-level logger is registered before we measure.
+ new BigQueryParameterHandler(1);
+ new BigQueryJdbcBulkInsertWriter();
+ int childrenBefore = childLoggerCount(rootLogger);
+
+ for (int i = 0; i < OBJECT_COUNT; i++) {
+ new BigQueryParameterHandler(1);
+ new BigQueryJdbcBulkInsertWriter();
+ }
+
+ assertEquals(
+ childrenBefore,
+ childLoggerCount(rootLogger),
+ "Creating "
+ + OBJECT_COUNT
+ + " ParameterHandlers and BulkInsertWriters must not register additional child loggers"
+ + " on the driver root logger");
+ }
+
+ @Test
+ public void testCreatingDatabaseMetaDataDoesNotRegisterChildLoggers()
+ throws ReflectiveOperationException {
+ BigQueryConnection connection = mock(BigQueryConnection.class);
+ doReturn("leak-test-connection").when(connection).getConnectionId();
+
+ Logger rootLogger = BigQueryJdbcRootLogger.getRootLogger();
+ new BigQueryDatabaseMetaData(connection);
+ int childrenBefore = childLoggerCount(rootLogger);
+
+ for (int i = 0; i < OBJECT_COUNT; i++) {
+ new BigQueryDatabaseMetaData(connection);
+ }
+
+ assertEquals(
+ childrenBefore,
+ childLoggerCount(rootLogger),
+ "Creating "
+ + OBJECT_COUNT
+ + " DatabaseMetaData objects must not register additional child loggers on the driver"
+ + " root logger");
+ }
+
+ @Test
+ public void testConnectionScopedResultSetLoggersDoNotRegisterChildLoggers()
+ throws ReflectiveOperationException {
+ Logger rootLogger = BigQueryJdbcRootLogger.getRootLogger();
+ // Warm up so the per-class delegates (and their one-time registration) exist before measuring.
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryArrowResultSet.class, "warm-up")
+ .getArrowStructLogger()
+ .getArrowArrayLogger();
+ int childrenBefore = childLoggerCount(rootLogger);
+
+ for (int i = 0; i < OBJECT_COUNT; i++) {
+ BigQueryJdbcResultSetLogger.getLogger(BigQueryArrowResultSet.class, "conn-" + i)
+ .getArrowStructLogger()
+ .getArrowArrayLogger();
+ }
+
+ assertEquals(
+ childrenBefore,
+ childLoggerCount(rootLogger),
+ "Connection-scoped result-set loggers must not register child loggers on the driver root");
+ }
+
+ /**
+ * Reads the JDK-internal {@code Logger.kids} list. This field is private to {@code java.logging},
+ * so the surefire {@code argLine} opens {@code java.util.logging} to the test classpath.
+ */
+ private static int childLoggerCount(Logger logger) throws ReflectiveOperationException {
+ // Qualified because this file already imports com.google.cloud.bigquery.Field.
+ java.lang.reflect.Field kids = Logger.class.getDeclaredField("kids");
+ kids.setAccessible(true);
+ List> children = (List>) kids.get(logger);
+ return children == null ? 0 : children.size();
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtilityTest.java
index d580f95bbaed..55d9d4bde62b 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtilityTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtilityTest.java
@@ -26,6 +26,7 @@
import com.google.cloud.bigquery.DatasetId;
import com.google.cloud.bigquery.exception.BigQueryJdbcRuntimeException;
+import com.google.cloud.bigquery.jdbc.telemetry.v1.TelemetryPropertyUtility;
import java.util.Collections;
import java.util.Map;
import java.util.Properties;
@@ -367,4 +368,27 @@ public void testParseEnableTimestampPicos() {
String result2 = BigQueryJdbcUrlUtility.parseUriProperty(url2, "EnableTimestampPicos");
assertThat(result2).isEqualTo("0");
}
+
+ @Test
+ public void testParseDiagnosticTelemetryProperties() {
+ String url =
+ "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;"
+ + "ProjectId=MyBigQueryProject;"
+ + "EnableDiagnosticTelemetry=0";
+
+ assertThat(BigQueryJdbcUrlUtility.parseUriProperty(url, "EnableDiagnosticTelemetry"))
+ .isEqualTo("0");
+
+ DataSource ds = DataSource.fromUrl(url);
+ assertThat(ds.getEnableDiagnosticTelemetry()).isFalse();
+
+ Properties props = ds.createProperties();
+ assertThat(props.getProperty("EnableDiagnosticTelemetry")).isEqualTo("false");
+
+ String urlWithoutTelemetry =
+ "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;ProjectId=MyBigQueryProject";
+ DataSource defaultDs = DataSource.fromUrl(urlWithoutTelemetry);
+ assertThat(defaultDs.getEnableDiagnosticTelemetry())
+ .isEqualTo(TelemetryPropertyUtility.DEFAULT_ENABLE_DIAGNOSTIC_TELEMETRY_VALUE);
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java
index f2127a552077..8c8af5657132 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java
@@ -458,4 +458,27 @@ public void testJsonArrayTimestampPicosDisabled() throws SQLException {
assertThat(rs.getString(2)).isEqualTo("2023-03-30 11:14:19.123456");
assertThat(rs.getObject(2)).isEqualTo(expectedTs);
}
+
+ @Test
+ public void testJsonArrayTimePreservesMicroseconds() throws SQLException {
+ Tuple schemaAndValue =
+ arraySchemaAndValue(TIME, "14:30:00.123456", "23:59:59.999999", "01:00:00");
+ BigQueryJsonArray array = new BigQueryJsonArray(schemaAndValue.x(), schemaAndValue.y());
+
+ assertThat(array.toString()).isEqualTo("[14:30:00.123456, 23:59:59.999999, 01:00:00]");
+
+ try (ResultSet rs = array.getResultSet()) {
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString(2)).isEqualTo("14:30:00.123456");
+ assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(14, 30, 0, 123_456_000));
+
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString(2)).isEqualTo("23:59:59.999999");
+ assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(23, 59, 59, 999_999_000));
+
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString(2)).isEqualTo("01:00:00");
+ assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(1, 0, 0));
+ }
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java
index 6620cab2ca5c..6c1b56c58c28 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java
@@ -193,6 +193,20 @@ public void structOfArrays() throws SQLException {
.isEqualTo(new String[] {"POINT(-122 47)", "POINT(-122 48)"});
assertThat(((Array) attributes[11]).getArray())
.isEqualTo(new byte[][] {"one".getBytes(), "two".getBytes()});
+ assertThat(struct.toString())
+ .isEqualTo(
+ "{\"INT64_arr_column\":[10,20],"
+ + "\"BOOL_arr_column\":[true,false],"
+ + "\"FLOAT64_arr_column\":[11.2,33.4],"
+ + "\"NUMERIC_arr_column\":[11.2657,33.4657],"
+ + "\"BIGNUMERIC_arr_column\":[11.2657,33.4657],"
+ + "\"STRING_arr_column\":[\"one\",\"two\"],"
+ + "\"TIMESTAMP_arr_column\":[\"2023-03-30 11:14:19.82\",\"2023-03-31 11:14:19.82\"],"
+ + "\"DATE_arr_column\":[\"2023-03-30\",\"2023-03-31\"],"
+ + "\"TIME_arr_column\":[\"11:14:19.82\",\"11:14:20.82\"],"
+ + "\"DATETIME_arr_column\":[\"2023-03-30 11:14:19.82\",\"2023-03-30 11:15:19.82\"],"
+ + "\"GEOGRAPHY_arr_column\":[\"POINT(-122 47)\",\"POINT(-122 48)\"],"
+ + "\"BYTES_arr_column\":[\"b25l\",\"dHdv\"]}");
}
@Test
@@ -295,4 +309,16 @@ public void testJsonStructTimestampPicosDisabled() throws SQLException {
Timestamp expectedTs = Timestamp.valueOf("2023-03-30 11:14:19.123456789");
assertThat(attributes).isEqualTo(new Object[] {expectedTs});
}
+
+ @Test
+ public void testJsonStructTimePreservesMicroseconds() {
+ Field timeField = Field.of("t", TIME);
+ FieldList schema = FieldList.of(timeField);
+ FieldValue val = FieldValue.of(PRIMITIVE, "14:30:00.123456");
+ FieldValue structValue = FieldValue.of(RECORD, FieldValueList.of(asList(val)));
+
+ BigQueryJsonStruct struct = new BigQueryJsonStruct(schema, structValue);
+
+ assertThat(struct.toString()).isEqualTo("{\"t\":\"14:30:00.123456\"}");
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java
index 9245f627c147..41f3731c2806 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java
@@ -33,6 +33,8 @@
import static org.mockito.Mockito.withSettings;
import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.ServerStream;
+import com.google.api.gax.rpc.ServerStreamingCallable;
import com.google.api.gax.rpc.StatusCode;
import com.google.cloud.ServiceOptions;
import com.google.cloud.Tuple;
@@ -62,11 +64,15 @@
import com.google.cloud.bigquery.exception.BigQueryJdbcException;
import com.google.cloud.bigquery.jdbc.BigQueryStatement.JobIdWrapper;
import com.google.cloud.bigquery.spi.BigQueryRpcFactory;
+import com.google.cloud.bigquery.storage.v1.ArrowRecordBatch;
import com.google.cloud.bigquery.storage.v1.ArrowSchema;
import com.google.cloud.bigquery.storage.v1.ArrowSerializationOptions;
import com.google.cloud.bigquery.storage.v1.BigQueryReadClient;
import com.google.cloud.bigquery.storage.v1.CreateReadSessionRequest;
+import com.google.cloud.bigquery.storage.v1.ReadRowsRequest;
+import com.google.cloud.bigquery.storage.v1.ReadRowsResponse;
import com.google.cloud.bigquery.storage.v1.ReadSession;
+import com.google.cloud.bigquery.storage.v1.ReadStream;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import io.opentelemetry.api.baggage.Baggage;
@@ -83,14 +89,19 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import org.apache.arrow.memory.RootAllocator;
import org.apache.arrow.vector.BitVector;
@@ -1318,4 +1329,235 @@ public void testJoblessQueryPopulatesQueryStatistics() throws Exception {
verify(bigquery, Mockito.never()).getJob(any(JobId.class));
}
+
+ @Test
+ public void testProcessArrowResultSetUsesBoundedQueueCapacity() throws SQLException {
+ BigQueryStatement defaultStmtSpy = Mockito.spy(bigQueryStatement);
+ BigQueryReadClient bigQueryReadClient = mock(BigQueryReadClient.class);
+ Schema schema = Schema.of(fieldList);
+ ReadSession readSession = ReadSession.getDefaultInstance();
+ doReturn(bigQueryReadClient).when(defaultStmtSpy).getBigQueryReadClient();
+ doReturn(readSession).when(defaultStmtSpy).getReadSession(any(CreateReadSessionRequest.class));
+ Future> mockWorker = mock(Future.class);
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> defaultQueueCaptor =
+ ArgumentCaptor.forClass(BlockingQueue.class);
+ doReturn(mockWorker)
+ .when(defaultStmtSpy)
+ .populateArrowBufferedQueue(
+ any(ReadSession.class), defaultQueueCaptor.capture(), any(BigQueryReadClient.class));
+ doReturn(arrowSchema).when(defaultStmtSpy).getArrowSchema(any(ReadSession.class));
+
+ TableResult result = mock(TableResult.class);
+ doReturn(schema).when(result).getSchema();
+ doReturn(10L).when(result).getTotalRows();
+ doReturn(TABLE_ID).when(defaultStmtSpy).getDestinationTable(any());
+
+ defaultStmtSpy.processArrowResultSet(result, null);
+ assertEquals(
+ BigQueryStatement.DEFAULT_ARROW_BUFFER_SIZE,
+ defaultQueueCaptor.getValue().remainingCapacity());
+ }
+
+ @Test
+ public void testPopulateArrowBufferedQueueStopsAndCancelsStreamWhenResultSetClosedWhileQueueFull()
+ throws Exception {
+ BlockingQueue boundedQueue = new LinkedBlockingDeque<>(1);
+ ReadSession readSession =
+ ReadSession.newBuilder()
+ .addStreams(
+ ReadStream.newBuilder().setName("projects/p/locations/l/sessions/s/streams/0"))
+ .build();
+
+ ReadRowsResponse batchResponse =
+ ReadRowsResponse.newBuilder()
+ .setArrowRecordBatch(ArrowRecordBatch.getDefaultInstance())
+ .build();
+
+ CountDownLatch secondBatchReturnedLatch = new CountDownLatch(1);
+ AtomicBoolean streamCancelled = new AtomicBoolean(false);
+
+ @SuppressWarnings("unchecked")
+ ServerStream serverStream = mock(ServerStream.class);
+ Mockito.doAnswer(
+ invocation -> {
+ streamCancelled.set(true);
+ return null;
+ })
+ .when(serverStream)
+ .cancel();
+
+ Iterator responseIterator =
+ new Iterator() {
+ private int count = 0;
+
+ @Override
+ public boolean hasNext() {
+ return true;
+ }
+
+ @Override
+ public ReadRowsResponse next() {
+ count++;
+ if (count == 2) {
+ secondBatchReturnedLatch.countDown();
+ }
+ return batchResponse;
+ }
+ };
+ doReturn(responseIterator).when(serverStream).iterator();
+
+ @SuppressWarnings("unchecked")
+ ServerStreamingCallable callable =
+ mock(ServerStreamingCallable.class);
+ doReturn(serverStream).when(callable).call(any(ReadRowsRequest.class));
+
+ BigQueryReadClient readClient = mock(BigQueryReadClient.class);
+ doReturn(callable).when(readClient).readRowsCallable();
+
+ Future> producerFuture =
+ bigQueryStatement.populateArrowBufferedQueue(readSession, boundedQueue, readClient);
+
+ // Wait until the first batch is in the queue and the second batch has been yielded by the
+ // stream iterator (so the producer thread is blocked or about to block on boundedQueue.put()).
+ assertTrue(secondBatchReturnedLatch.await(5, TimeUnit.SECONDS));
+ assertEquals(1, boundedQueue.size());
+
+ BigQueryArrowResultSet rs =
+ BigQueryArrowResultSet.of(
+ Schema.of(fieldList),
+ arrowSchema,
+ 100L,
+ bigQueryStatement,
+ boundedQueue,
+ producerFuture,
+ bigquery);
+
+ // Closing the ResultSet while the queue is full must cancel the producer task,
+ // unblock the producer thread, cancel the underlying ServerStream, and clear the buffer.
+ rs.close();
+
+ assertTrue(producerFuture.isCancelled());
+ testExecutorService.shutdown();
+ assertTrue(
+ testExecutorService.awaitTermination(5, TimeUnit.SECONDS),
+ "Producer thread should terminate promptly after ResultSet.close()");
+ assertTrue(streamCancelled.get(), "ServerStream.cancel() should be called on early close");
+ assertTrue(
+ boundedQueue.stream().allMatch(w -> w.getCurrentArrowBatch() == null),
+ "No ArrowRecordBatch should remain buffered after ResultSet.close()");
+ }
+
+ @Test
+ public void testCancelStatementUnblocksConcurrentArrowResultSetNext() throws Exception {
+ BlockingQueue emptyQueue = new LinkedBlockingDeque<>(4);
+ Future> producerFuture = mock(Future.class);
+ BigQueryArrowResultSet rs =
+ BigQueryArrowResultSet.of(
+ Schema.of(fieldList),
+ arrowSchema,
+ 100L,
+ bigQueryStatement,
+ emptyQueue,
+ producerFuture,
+ bigquery);
+ bigQueryStatement.currentResultSet = rs;
+
+ CountDownLatch readerEnteredNext = new CountDownLatch(1);
+ Future readerFuture =
+ testExecutorService.submit(
+ () -> {
+ readerEnteredNext.countDown();
+ rs.next();
+ return null;
+ });
+
+ assertTrue(readerEnteredNext.await(5, TimeUnit.SECONDS));
+ // Give the reader thread a brief moment to block inside emptyQueue.take()
+ Thread.sleep(50);
+
+ bigQueryStatement.cancel();
+
+ ExecutionException ex =
+ assertThrows(ExecutionException.class, () -> readerFuture.get(5, TimeUnit.SECONDS));
+ assertTrue(ex.getCause() instanceof SQLException);
+ }
+
+ @Test
+ public void testCancelStatementUnblocksConcurrentJsonResultSetNext() throws Exception {
+ BlockingQueue emptyQueue = new LinkedBlockingDeque<>(4);
+ BigQueryJsonResultSet rs =
+ BigQueryJsonResultSet.of(
+ Schema.of(fieldList), 100L, emptyQueue, bigQueryStatement, null, bigquery);
+ bigQueryStatement.currentResultSet = rs;
+
+ CountDownLatch readerEnteredNext = new CountDownLatch(1);
+ Future readerFuture =
+ testExecutorService.submit(
+ () -> {
+ readerEnteredNext.countDown();
+ rs.next();
+ return null;
+ });
+
+ assertTrue(readerEnteredNext.await(5, TimeUnit.SECONDS));
+ Thread.sleep(50);
+
+ bigQueryStatement.cancel();
+
+ ExecutionException ex =
+ assertThrows(ExecutionException.class, () -> readerFuture.get(5, TimeUnit.SECONDS));
+ assertTrue(ex.getCause() instanceof SQLException);
+ }
+
+ @Test
+ public void testPopulateArrowBufferedQueueEnqueuesErrorWhenWorkerShuttingDown() throws Exception {
+ BlockingQueue boundedQueue = new LinkedBlockingDeque<>(4);
+ ReadSession readSession =
+ ReadSession.newBuilder()
+ .addStreams(
+ ReadStream.newBuilder().setName("projects/p/locations/l/sessions/s/streams/0"))
+ .build();
+
+ ReadRowsResponse batchResponse =
+ ReadRowsResponse.newBuilder()
+ .setArrowRecordBatch(ArrowRecordBatch.getDefaultInstance())
+ .build();
+
+ @SuppressWarnings("unchecked")
+ ServerStream serverStream = mock(ServerStream.class);
+ Iterator responseIterator =
+ new Iterator() {
+ @Override
+ public boolean hasNext() {
+ // Simulate executor shutdown mid-stream before the next batch is processed
+ testExecutorService.shutdownNow();
+ return true;
+ }
+
+ @Override
+ public ReadRowsResponse next() {
+ return batchResponse;
+ }
+ };
+ doReturn(responseIterator).when(serverStream).iterator();
+
+ @SuppressWarnings("unchecked")
+ ServerStreamingCallable callable =
+ mock(ServerStreamingCallable.class);
+ doReturn(serverStream).when(callable).call(any(ReadRowsRequest.class));
+
+ BigQueryReadClient readClient = mock(BigQueryReadClient.class);
+ doReturn(callable).when(readClient).readRowsCallable();
+
+ Future> producerFuture =
+ bigQueryStatement.populateArrowBufferedQueue(readSession, boundedQueue, readClient);
+ producerFuture.get(5, TimeUnit.SECONDS);
+
+ BigQueryArrowBatchWrapper marker = boundedQueue.poll(5, TimeUnit.SECONDS);
+ assertNotNull(marker);
+ assertNotNull(
+ marker.getException(),
+ "Stopping early due to worker shutdown must enqueue an error marker rather than normal end-of-stream");
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java
index 442182e7f2d3..1223b0d11a12 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java
@@ -263,4 +263,33 @@ public void testDatetimeAndTimestampCrossTypeConversionWithZone() throws Excepti
"2025-01-01 12:34:56Z", StandardSQLTypeName.TIMESTAMP, Time.class, la);
assertThat(tsTimeUtc).isEqualTo(tsTimeLa);
}
+
+ @Test
+ public void testTimeMicrosecondPrecision() throws BigQueryJdbcException {
+ String timeStr = "14:30:00.123456";
+ long timeMicros = (14L * 3600 + 30L * 60) * 1_000_000L + 123_456L;
+ LocalTime expectedLocalTime = LocalTime.of(14, 30, 0, 123_456_000);
+
+ // Direct conversion to LocalTime and String preserves microseconds
+ assertThat(
+ BigQueryTypeRegistry.convert(timeStr, StandardSQLTypeName.TIME, LocalTime.class, null))
+ .isEqualTo(expectedLocalTime);
+ assertThat(
+ BigQueryTypeRegistry.convert(
+ timeMicros, StandardSQLTypeName.TIME, LocalTime.class, null))
+ .isEqualTo(expectedLocalTime);
+ assertThat(BigQueryTypeRegistry.convert(timeStr, StandardSQLTypeName.TIME, String.class, null))
+ .isEqualTo("14:30:00.123456");
+ assertThat(
+ BigQueryTypeRegistry.convert(timeMicros, StandardSQLTypeName.TIME, String.class, null))
+ .isEqualTo("14:30:00.123456");
+ assertThat(
+ BigQueryTypeRegistry.convert("12:30:00", StandardSQLTypeName.TIME, String.class, null))
+ .isEqualTo("12:30:00");
+
+ // Standard java.sql.Time conversion retains pure JDK Time contract
+ Time sqlTime = (Time) BigQueryTypeRegistry.convert(timeStr, StandardSQLTypeName.TIME, null);
+ assertThat(sqlTime.getClass()).isEqualTo(Time.class);
+ assertThat(sqlTime.toString()).isEqualTo("14:30:00");
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBase.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBase.java
index 80501e126712..98d7a7c014ba 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBase.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBase.java
@@ -137,9 +137,10 @@ public class ITBase extends BigQueryJdbcBaseTest {
+ " timestampFiled TIMESTAMP,\n"
+ " dateField DATE,\n"
+ " timeField TIME,\n"
+ + " timeMicrosField TIME,\n"
+ " dateTimeField DATETIME,\n"
+ " geographyField GEOGRAPHY,\n"
- + " recordField STRUCT>,\n"
+ + " recordField STRUCT>,\n"
+ " rangeField RANGE,\n"
+ " jsonField JSON,\n"
+ " arrayString ARRAY,\n"
@@ -163,15 +164,15 @@ public class ITBase extends BigQueryJdbcBaseTest {
"INSERT INTO `%1$s.%2$s.all_bq_types`\n"
+ "(\n"
+ " stringField, bytesField, intField, floatField, numericField, bigNumericField, booleanField,\n"
- + " timestampFiled, dateField, timeField, dateTimeField, geographyField, recordField, rangeField, jsonField,\n"
+ + " timestampFiled, dateField, timeField, timeMicrosField, dateTimeField, geographyField, recordField, rangeField, jsonField,\n"
+ " arrayString, arrayRecord, arrayBytes, arrayInteger, arrayNumeric, arrayBignumeric, arrayBoolean,\n"
+ " arrayTimestamp, arrayDate, arrayTime, arrayDatetime, arrayGeography, arrayRange, arrayJson, arrayFloat\n"
+ ")\n"
+ "VALUES\n"
+ "(\n"
+ " 'StringValue', CAST('BytesValue' AS BYTES), 123, 10.5, CAST('12345.67' AS NUMERIC), CAST('98765432109876543210.123456789' AS BIGNUMERIC), true,\n"
- + " TIMESTAMP '2023-07-28 12:30:00', DATE '2023-07-28', TIME '12:30:00', DATETIME '2023-07-28 12:30:00', ST_GEOGFROMTEXT('POINT(-74.006 40.7128)'),\n"
- + " STRUCT('NameValue' AS name, STRUCT('LastNameValue' AS lastName) AS recordNested),\n"
+ + " TIMESTAMP '2023-07-28 12:30:00', DATE '2023-07-28', TIME '12:30:00', TIME '12:30:00.123456', DATETIME '2023-07-28 12:30:00', ST_GEOGFROMTEXT('POINT(-74.006 40.7128)'),\n"
+ + " STRUCT('NameValue' AS name, TIME '12:30:00.123456' AS timeVal, STRUCT('LastNameValue' AS lastName) AS recordNested),\n"
+ " RANGE '[2023-01-01, 2023-12-01)', JSON '{\"key\":\"value\"}',\n"
+ " ['abc', 'def', 'ghi'],\n"
+ " [STRUCT('rec_val1' AS value), STRUCT('rec_val2' AS value)],\n"
@@ -182,7 +183,7 @@ public class ITBase extends BigQueryJdbcBaseTest {
+ " [true, false],\n"
+ " [TIMESTAMP '2023-01-01 01:00:00', TIMESTAMP '2023-01-01 02:00:00'],\n"
+ " [DATE '2023-01-01', DATE '2023-01-02'],\n"
- + " [TIME '01:00:00', TIME '02:00:00'],\n"
+ + " [TIME '01:00:00', TIME '02:00:00.123456'],\n"
+ " [DATETIME '2023-01-01 01:00:00', DATETIME '2023-01-01 02:00:00'],\n"
+ " [ST_GEOGFROMTEXT('POINT(1 1)'), ST_GEOGFROMTEXT('POINT(2 2)')],\n"
+ " [RANGE '[2023-01-01, 2023-01-03)', RANGE '[2023-01-04, 2023-01-06)'],\n"
@@ -191,7 +192,7 @@ public class ITBase extends BigQueryJdbcBaseTest {
+ "),\n"
+ "(\n"
+ " NULL, NULL, NULL, NULL, NULL, NULL, NULL,\n"
- + " NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,\n"
+ + " NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,\n"
+ " [], [], [], [], [], [], [],\n"
+ " [], [], [], [], [], [], [], []\n"
+ ");\n";
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java
index 366e95b70d46..31853252d06d 100644
--- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java
@@ -71,6 +71,8 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
public class ITBigQueryJDBCTest extends ITBase {
static final String PROJECT_ID = ServiceOptions.getDefaultProjectId();
@@ -2707,12 +2709,13 @@ public void validateGetString() throws Exception {
.put("booleanField", "true")
.put("timestampFiled", expectedTimestampString)
.put("dateField", "2023-07-28")
- .put("timeField", "12:30:00.000")
+ .put("timeField", "12:30:00")
+ .put("timeMicrosField", "12:30:00.123456")
.put("dateTimeField", "2023-07-28 12:30:00.000000")
.put("geographyField", "POINT(-74.006 40.7128)")
.put(
"recordField",
- "{\"name\":\"NameValue\",\"recordNested\":{\"lastName\":\"LastNameValue\"}}")
+ "{\"name\":\"NameValue\",\"timeVal\":\"12:30:00.123456\",\"recordNested\":{\"lastName\":\"LastNameValue\"}}")
.put("rangeField", "[2023-01-01, 2023-12-01)")
.put("jsonField", "{\"key\":\"value\"}")
.put("arrayString", "[abc, def, ghi]")
@@ -2724,7 +2727,7 @@ public void validateGetString() throws Exception {
.put("arrayBoolean", "[true, false]")
.put("arrayTimestamp", "[2023-01-01 01:00:00.0, 2023-01-01 02:00:00.0]")
.put("arrayDate", "[2023-01-01, 2023-01-02]")
- .put("arrayTime", "[01:00:00, 02:00:00]")
+ .put("arrayTime", "[01:00:00, 02:00:00.123456]")
.put("arrayDatetime", "[2023-01-01 01:00:00.0, 2023-01-01 02:00:00.0]")
.put("arrayGeography", "[POINT(1 1), POINT(2 2)]")
.put("arrayRange", "[[2023-01-01, 2023-01-03), [2023-01-04, 2023-01-06)]")
@@ -2880,6 +2883,7 @@ public void validateGetTime() throws Exception {
final ImmutableMap result =
new ImmutableMap.Builder()
.put("timeField", Time.valueOf("12:30:00"))
+ .put("timeMicrosField", new Time(Time.valueOf("12:30:00").getTime() + 123))
.put("dateTimeField", Time.valueOf("12:30:00"))
.put("timestampFiled", Time.valueOf(expectedTimestampLocalTime))
.build();
@@ -2922,6 +2926,7 @@ public void validateGetTimestamp() throws Exception {
final ImmutableMap result =
new ImmutableMap.Builder()
.put("timeField", Timestamp.valueOf("1970-01-01 12:30:00"))
+ .put("timeMicrosField", Timestamp.valueOf("1970-01-01 12:30:00.123"))
.put("dateField", Timestamp.valueOf("2023-07-28 00:00:00"))
.put("dateTimeField", Timestamp.valueOf("2023-07-28 12:30:00"))
.put("timestampFiled", Timestamp.from(Instant.parse("2023-07-28T12:30:00Z")))
@@ -3092,4 +3097,33 @@ public void testSessionAbortedOnConnectionClose() throws SQLException {
}
}
}
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", FORCE_READ_API_PROPERTIES})
+ public void testStructWithNestedArraysValidJson(String extraProps) throws SQLException {
+ String sql =
+ "SELECT STRUCT("
+ + "1 AS id, "
+ + "['hello', 'world'] AS str_arr, "
+ + "[DATE '2026-01-01', DATE '2026-01-02'] AS date_arr, "
+ + "[TIME '12:34:56.789012'] AS time_arr, "
+ + "[TIMESTAMP '2026-01-01 00:00:00 UTC'] AS ts_arr, "
+ + "[b'abc', b'def'] AS bytes_arr"
+ + ") AS s_nested_arrays";
+
+ String expectedJson =
+ "{\"id\":1,"
+ + "\"str_arr\":[\"hello\",\"world\"],"
+ + "\"date_arr\":[\"2026-01-01\",\"2026-01-02\"],"
+ + "\"time_arr\":[\"12:34:56.789012\"],"
+ + "\"ts_arr\":[\"2026-01-01 00:00:00.0\"],"
+ + "\"bytes_arr\":[\"YWJj\",\"ZGVm\"]}";
+
+ try (Connection conn = DriverManager.getConnection(connection_uri + extraProps);
+ Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery(sql)) {
+ assertTrue(rs.next());
+ assertEquals(expectedJson, rs.getString("s_nested_arrays"));
+ }
+ }
}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/BigQueryJdbcPropertyUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/BigQueryJdbcPropertyUtilityTest.java
new file mode 100644
index 000000000000..201092e27a80
--- /dev/null
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/BigQueryJdbcPropertyUtilityTest.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2026 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.
+ */
+
+package com.google.cloud.bigquery.jdbc.telemetry.v1;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class BigQueryJdbcPropertyUtilityTest {
+
+ @Test
+ public void testConvertStringToBoolean_validValues() {
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean("1", false));
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean("true", false));
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean("True", false));
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean("TRUE", false));
+
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean("0", true));
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean("false", true));
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean("False", true));
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean("FALSE", true));
+ }
+
+ @Test
+ public void testConvertStringToBoolean_invalidAndNullValues() {
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean("2", true));
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean("2", false));
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean("-1", true));
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean("-1", false));
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean("invalid", true));
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean("invalid", false));
+ assertTrue(TelemetryPropertyUtility.convertStringToBoolean(null, true));
+ assertFalse(TelemetryPropertyUtility.convertStringToBoolean(null, false));
+ }
+}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java
new file mode 100644
index 000000000000..2e958cd7904e
--- /dev/null
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java
@@ -0,0 +1,263 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.api.client.http.LowLevelHttpRequest;
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class ClearcutTransportTest {
+
+ @BeforeEach
+ @AfterEach
+ public void resetTelemetry() {
+ TelemetryTestUtils.reset();
+ }
+
+ @Test
+ public void testSendWhenDisabled() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest();
+ }
+ };
+
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().setEnabled(false).build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TransportResult result = transport.send(TelemetryPayload.getDefaultInstance());
+ assertFalse(result.isSuccess());
+ assertEquals(-1, result.getNextRequestWaitMillis());
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ public void testSendNullPayloadFailsSilently() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest();
+ }
+ };
+
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().setEnabled(true).build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TransportResult result = transport.send(null);
+ assertFalse(result.isSuccess());
+ assertEquals(-1, result.getNextRequestWaitMillis());
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ public void testSendSuccess() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().setEnabled(true).build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TransportResult result = transport.send(TelemetryPayload.getDefaultInstance());
+ assertTrue(result.isSuccess());
+ assertEquals(-1, result.getNextRequestWaitMillis());
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ public void testSendServerErrorStatusCodeReturnsFalse() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(500);
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().setEnabled(true).build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TransportResult result = transport.send(TelemetryPayload.getDefaultInstance());
+ assertFalse(result.isSuccess());
+ assertEquals(-1, result.getNextRequestWaitMillis());
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ public void testSendClientErrorStatusCodeReturnsFalse() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(400);
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().setEnabled(true).build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TransportResult result = transport.send(TelemetryPayload.getDefaultInstance());
+ assertFalse(result.isSuccess());
+ assertEquals(-1, result.getNextRequestWaitMillis());
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ public void testSendIOExceptionReturnsFalse() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
+ requestCount.incrementAndGet();
+ throw new IOException("Network unreachable");
+ }
+ };
+
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().setEnabled(true).build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TransportResult result = transport.send(TelemetryPayload.getDefaultInstance());
+ assertFalse(result.isSuccess());
+ assertEquals(-1, result.getNextRequestWaitMillis());
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ public void testSendServerErrorWithNextRequestWaitMillis() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(429);
+ LogResponse logResponse =
+ LogResponse.newBuilder().setNextRequestWaitMillis(10).build();
+ response.setContent(logResponse.toByteArray());
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().setEnabled(true).build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TransportResult result = transport.send(TelemetryPayload.getDefaultInstance());
+ assertFalse(result.isSuccess());
+ assertEquals(1, requestCount.get());
+ assertEquals(10, result.getNextRequestWaitMillis());
+ }
+
+ @Test
+ public void testRecordError_reachesPayload() throws Exception {
+ AtomicReference captured = new AtomicReference<>();
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ getStreamingContent().writeTo(out);
+ captured.set(out.toByteArray());
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ return response;
+ }
+ };
+ }
+ };
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setDriverEnvironment(
+ DriverEnvironment.newBuilder().setDriverName("SimulatedDriver").build())
+ .build();
+ TelemetryManager.init(config, new ClearcutTransport(mockTransport, config));
+ TelemetryManager.recordError(404, "42000", "executeQuery");
+ TelemetryManager.getInstance().getBatcher().flush();
+ LogRequest request = LogRequest.parseFrom(captured.get());
+ TelemetryPayload payload =
+ TelemetryPayload.parseFrom(request.getLogEvents(0).getSourceExtension());
+ assertEquals(1, payload.getErrorsCount());
+ assertEquals(
+ TelemetryConfiguration.DEFAULT_CLIENT_TYPE, request.getClientInfo().getClientType());
+ ErrorMetric error = payload.getErrors(0);
+ assertEquals(404, error.getErrorCode());
+ assertEquals("42000", error.getErrorSqlState());
+ assertEquals("executeQuery", error.getMethodName());
+ assertEquals(1, error.getCount());
+ }
+}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetectorTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetectorTest.java
new file mode 100644
index 000000000000..7d20f5d7166f
--- /dev/null
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetectorTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class DriverEnvironmentDetectorTest {
+
+ @Test
+ public void testBuildDriverEnvironment() {
+ DriverEnvironment env = DriverEnvironmentDetector.build();
+ assertNotNull(env);
+ assertEquals("Google-BigQuery-JDBC-Driver", env.getDriverName());
+ assertNotNull(env.getDriverVersion());
+ assertEquals("java", env.getClientLanguage());
+ assertNotNull(env.getClientLanguageVersion());
+ assertNotNull(env.getOsType());
+ assertNotNull(env.getOsVersion());
+ assertNotNull(env.getTelemetryTag());
+ }
+
+ @Test
+ public void testBuildDriverEnvironmentCustomTagPath(@TempDir Path tempDir) {
+ Path tagFile = tempDir.resolve("telemetry-tag");
+ DriverEnvironment env = DriverEnvironmentDetector.build(tagFile);
+ assertNotNull(env);
+ assertEquals("Google-BigQuery-JDBC-Driver", env.getDriverName());
+ assertNotNull(env.getDriverVersion());
+ assertEquals("java", env.getClientLanguage());
+ assertNotNull(env.getClientLanguageVersion());
+ assertNotNull(env.getOsType());
+ assertNotNull(env.getOsVersion());
+ assertEquals(env.getTelemetryTag(), DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile));
+ }
+
+ @Test
+ public void testGetMajorJavaVersion() {
+ assertEquals("8", DriverEnvironmentDetector.getMajorJavaVersion("1.8.0_292"));
+ assertEquals("11", DriverEnvironmentDetector.getMajorJavaVersion("11.0.12"));
+ assertEquals("17", DriverEnvironmentDetector.getMajorJavaVersion("17.0.1"));
+ assertEquals("21", DriverEnvironmentDetector.getMajorJavaVersion("21"));
+ assertEquals("unknown", DriverEnvironmentDetector.getMajorJavaVersion(null));
+ assertEquals("unknown", DriverEnvironmentDetector.getMajorJavaVersion(" "));
+ }
+
+ @Test
+ public void testDetectOsType() {
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_WINDOWS,
+ DriverEnvironmentDetector.detectOsType("Windows 11"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_MACOS, DriverEnvironmentDetector.detectOsType("Mac OS X"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_MACOS, DriverEnvironmentDetector.detectOsType("Darwin"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_LINUX, DriverEnvironmentDetector.detectOsType("Linux"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_SOLARIS,
+ DriverEnvironmentDetector.detectOsType("Solaris"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_FREEBSD,
+ DriverEnvironmentDetector.detectOsType("FreeBSD"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_OPENBSD,
+ DriverEnvironmentDetector.detectOsType("OpenBSD"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_NETBSD, DriverEnvironmentDetector.detectOsType("NetBSD"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_AIX, DriverEnvironmentDetector.detectOsType("AIX"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_UNKNOWN,
+ DriverEnvironmentDetector.detectOsType("UnknownOS"));
+ assertEquals(
+ DriverEnvironment.OsType.OS_TYPE_UNKNOWN, DriverEnvironmentDetector.detectOsType(null));
+ }
+
+ @Test
+ public void testGetMajorOsVersion() {
+ assertEquals("10", DriverEnvironmentDetector.getMajorOsVersion("10.0"));
+ assertEquals("6", DriverEnvironmentDetector.getMajorOsVersion("6.1.0"));
+ assertEquals("5", DriverEnvironmentDetector.getMajorOsVersion("5"));
+ assertEquals("unknown", DriverEnvironmentDetector.getMajorOsVersion(null));
+ assertEquals("unknown", DriverEnvironmentDetector.getMajorOsVersion(" "));
+ }
+
+ @Test
+ public void testGetOrCreateTelemetryTag_CreateNew(@TempDir Path tempDir) {
+ Path tagFile = tempDir.resolve("telemetry-tag");
+ String tag = DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile);
+
+ assertNotNull(tag);
+ assertTrue(Files.exists(tagFile));
+ // Verify it is a valid UUID
+ assertEquals(tag, UUID.fromString(tag).toString());
+ }
+
+ @Test
+ public void testGetOrCreateTelemetryTag_ReadExisting(@TempDir Path tempDir) throws IOException {
+ Path tagFile = tempDir.resolve("telemetry-tag");
+ String existingUuid = UUID.randomUUID().toString();
+ Files.write(tagFile, existingUuid.getBytes(StandardCharsets.UTF_8));
+
+ String tag = DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile);
+ assertEquals(existingUuid, tag);
+ }
+
+ @Test
+ public void testGetOrCreateTelemetryTag_RegenerateOnCorruptedFile(@TempDir Path tempDir)
+ throws IOException {
+ Path tagFile = tempDir.resolve("telemetry-tag");
+ String corruptedContent = "not-a-valid-uuid";
+ Files.write(tagFile, corruptedContent.getBytes(StandardCharsets.UTF_8));
+
+ String newTag = DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile);
+
+ assertNotNull(newTag);
+ assertNotEquals(corruptedContent, newTag);
+ assertEquals(newTag, UUID.fromString(newTag).toString());
+ // Verify file on disk was overwritten with new valid UUID
+ String fileOnDisk = new String(Files.readAllBytes(tagFile), StandardCharsets.UTF_8).trim();
+ assertEquals(newTag, fileOnDisk);
+ }
+}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcherTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcherTest.java
new file mode 100644
index 000000000000..6f6ec1ae31d4
--- /dev/null
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcherTest.java
@@ -0,0 +1,608 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.api.client.http.LowLevelHttpRequest;
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.protobuf.Message;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TelemetryBatcherTest {
+
+ private ScheduledExecutorService executorService;
+ private DriverEnvironment driverEnvironment;
+
+ @BeforeEach
+ public void setUp() {
+ executorService = Executors.newSingleThreadScheduledExecutor();
+ driverEnvironment =
+ DriverEnvironment.newBuilder()
+ .setDriverName("SimulatedDriver")
+ .setDriverVersion("1.0.0")
+ .build();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (executorService != null && !executorService.isShutdown()) {
+ executorService.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testOfferAndFlushSuccess() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setDriverEnvironment(driverEnvironment)
+ .build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ try (TelemetryBatcher batcher =
+ new TelemetryBatcher(config, transport, executorService, false)) {
+
+ batcher.offer(
+ ConnectionAttempt.newBuilder()
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .setAuthType(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT)
+ .build());
+ batcher.offer(
+ StatementExecution.newBuilder()
+ .setStatementType(StatementType.STATEMENT_TYPE_SELECT)
+ .setQueryApiType(QueryApiType.QUERY_API_TYPE_STANDARD_REST_API)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build(),
+ 150);
+ batcher.offer(
+ ErrorMetric.newBuilder()
+ .setErrorCode(1)
+ .setErrorSqlState("HY000")
+ .setMethodName("executeQuery")
+ .build());
+ batcher.offer(
+ FeatureUsage.newBuilder()
+ .setDriverFeature(DriverFeature.DRIVER_FEATURE_CUSTOM)
+ .setCustomFeatureName("MyFeature")
+ .build());
+
+ TransportResult result = batcher.flush();
+ assertTrue(result.isSuccess());
+ assertEquals(1, requestCount.get());
+
+ // Secondary flush should be empty
+ TransportResult result2 = batcher.flush();
+ assertFalse(result2.isSuccess()); // empty flush returns disabled or unsuccess
+ assertEquals(1, requestCount.get()); // no new request sent
+ }
+ }
+
+ @Test
+ public void testFlushFailureRequeuesEvents() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(500); // simulate failure
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setDriverEnvironment(driverEnvironment)
+ .build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ try (TelemetryBatcher batcher =
+ new TelemetryBatcher(config, transport, executorService, false)) {
+
+ batcher.offer(
+ ConnectionAttempt.newBuilder()
+ .setStatus(Status.STATUS_ERROR)
+ .setErrorCode(0)
+ .setAuthType(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT)
+ .build());
+
+ TransportResult result = batcher.flush();
+ assertFalse(result.isSuccess());
+ assertEquals(1, requestCount.get());
+
+ // Because it failed, the connection attempt should be merged back into the active map.
+ // We can verify this by flushing again with a working transport (we can't change transport
+ // mid-flight here,
+ // but we can verify it attempts another request).
+ TransportResult result2 = batcher.flush();
+ assertEquals(2, requestCount.get());
+ }
+ }
+
+ @Test
+ public void testCloseFlushesAndShutsDown() {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setDriverEnvironment(driverEnvironment)
+ .build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ TelemetryBatcher batcher = new TelemetryBatcher(config, transport);
+ batcher.offer(
+ ConnectionAttempt.newBuilder()
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .setAuthType(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT)
+ .build());
+
+ batcher.close();
+ assertEquals(1, requestCount.get());
+
+ // Should not accept new events after close
+ batcher.offer(
+ ConnectionAttempt.newBuilder()
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .setAuthType(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT)
+ .build());
+ batcher.flush();
+ // Flush should return disabled and not send a request
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ public void testTelemetryKeyFactoryAndEquality() {
+ StatementExecution stmt1 =
+ StatementExecution.newBuilder()
+ .setStatementType(StatementType.STATEMENT_TYPE_SELECT)
+ .setQueryApiType(QueryApiType.QUERY_API_TYPE_STANDARD_REST_API)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build();
+ StatementExecution stmt2 =
+ StatementExecution.newBuilder()
+ .setStatementType(StatementType.STATEMENT_TYPE_SELECT)
+ .setQueryApiType(QueryApiType.QUERY_API_TYPE_STANDARD_REST_API)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build();
+ StatementExecution stmtDiff =
+ StatementExecution.newBuilder()
+ .setStatementType(StatementType.STATEMENT_TYPE_INSERT)
+ .setQueryApiType(QueryApiType.QUERY_API_TYPE_STANDARD_REST_API)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build();
+
+ ConnectionAttempt conn1 =
+ ConnectionAttempt.newBuilder()
+ .setAuthType(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build();
+ ConnectionAttempt conn2 =
+ ConnectionAttempt.newBuilder()
+ .setAuthType(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build();
+
+ TelemetryBatcher.TelemetryKey keyStmt1 = TelemetryBatcher.TelemetryKey.from(stmt1);
+ TelemetryBatcher.TelemetryKey keyStmt2 = TelemetryBatcher.TelemetryKey.from(stmt2);
+ TelemetryBatcher.TelemetryKey keyStmtDiff = TelemetryBatcher.TelemetryKey.from(stmtDiff);
+ TelemetryBatcher.TelemetryKey keyConn1 = TelemetryBatcher.TelemetryKey.from(conn1);
+ TelemetryBatcher.TelemetryKey keyConn2 = TelemetryBatcher.TelemetryKey.from(conn2);
+
+ assertEquals(keyStmt1, keyStmt2);
+ assertEquals(keyStmt1.hashCode(), keyStmt2.hashCode());
+ assertNotEquals(keyStmt1, keyStmtDiff);
+ assertNotEquals(keyStmt1, keyConn1);
+ assertNotEquals(keyStmt1, null);
+ assertNotEquals(keyStmt1, new Object());
+ assertEquals(keyConn1, keyConn2);
+ assertEquals(keyConn1.hashCode(), keyConn2.hashCode());
+
+ assertTrue(keyStmt1.createAccumulator() instanceof TelemetryBatcher.StatementAccumulator);
+ assertTrue(keyConn1.createAccumulator() instanceof TelemetryBatcher.ConnectionAccumulator);
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> TelemetryBatcher.TelemetryKey.from(DurationHistogram.getDefaultInstance()));
+ }
+
+ @Test
+ public void testTelemetryAccumulatorWithMultipleProtos() {
+ // 1. Setup keys & accumulators from multiple distinct proto types
+ StatementExecution stmtProto =
+ StatementExecution.newBuilder()
+ .setStatementType(StatementType.STATEMENT_TYPE_SELECT)
+ .setQueryApiType(QueryApiType.QUERY_API_TYPE_STANDARD_REST_API)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build();
+ ConnectionAttempt connProto =
+ ConnectionAttempt.newBuilder()
+ .setAuthType(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT)
+ .setStatus(Status.STATUS_SUCCESS)
+ .setErrorCode(0)
+ .build();
+ ErrorMetric errorProto =
+ ErrorMetric.newBuilder()
+ .setErrorCode(101)
+ .setErrorSqlState("42000")
+ .setMethodName("executeQuery")
+ .build();
+ FeatureUsage featureProto =
+ FeatureUsage.newBuilder()
+ .setDriverFeature(DriverFeature.DRIVER_FEATURE_CUSTOM)
+ .setCustomFeatureName("CustomFeature")
+ .build();
+
+ TelemetryBatcher.TelemetryAccumulator stmtAcc1 =
+ TelemetryBatcher.TelemetryKey.from(stmtProto).createAccumulator();
+ stmtAcc1.accumulate(90);
+
+ TelemetryBatcher.TelemetryAccumulator stmtAcc2 =
+ TelemetryBatcher.TelemetryKey.from(stmtProto).createAccumulator();
+ stmtAcc2.accumulate(250);
+
+ TelemetryBatcher.TelemetryAccumulator connAcc1 =
+ TelemetryBatcher.TelemetryKey.from(connProto).createAccumulator();
+ connAcc1.accumulate(0);
+
+ TelemetryBatcher.TelemetryAccumulator connAcc2 =
+ TelemetryBatcher.TelemetryKey.from(connProto).createAccumulator();
+ connAcc2.accumulate(0);
+ connAcc2.accumulate(0);
+
+ TelemetryBatcher.TelemetryAccumulator errorAcc1 =
+ TelemetryBatcher.TelemetryKey.from(errorProto).createAccumulator();
+ errorAcc1.accumulate(0);
+
+ TelemetryBatcher.TelemetryAccumulator errorAcc2 =
+ TelemetryBatcher.TelemetryKey.from(errorProto).createAccumulator();
+ errorAcc2.accumulate(0);
+
+ TelemetryBatcher.TelemetryAccumulator featureAcc =
+ TelemetryBatcher.TelemetryKey.from(featureProto).createAccumulator();
+ featureAcc.accumulate(0);
+
+ // 2. Test merge behavior across accumulators
+ stmtAcc1.merge(stmtAcc2);
+ connAcc1.merge(connAcc2);
+ errorAcc1.merge(errorAcc2);
+
+ // 3. Test polymorphic addToPayload into a single payload builder
+ TelemetryPayload.Builder payloadBuilder = TelemetryPayload.newBuilder();
+ java.util.List accumulators =
+ java.util.Arrays.asList(stmtAcc1, connAcc1, errorAcc1, featureAcc);
+ for (TelemetryBatcher.TelemetryAccumulator acc : accumulators) {
+ acc.addToPayload(payloadBuilder);
+ }
+
+ // 4. Verify each metric type in the resulting payload
+ assertEquals(1, payloadBuilder.getStatementExecutionsCount());
+ StatementExecution exec = payloadBuilder.getStatementExecutions(0);
+ assertEquals(StatementType.STATEMENT_TYPE_SELECT, exec.getStatementType());
+ assertEquals(2, exec.getCount());
+ assertEquals(340, exec.getDuration().getSum());
+ assertEquals(2, exec.getDuration().getCount());
+ assertEquals(1, exec.getDuration().getBucketCounts(1));
+ assertEquals(1, exec.getDuration().getBucketCounts(3));
+
+ assertEquals(1, payloadBuilder.getConnectionAttemptsCount());
+ ConnectionAttempt conn = payloadBuilder.getConnectionAttempts(0);
+ assertEquals(AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT, conn.getAuthType());
+ assertEquals(Status.STATUS_SUCCESS, conn.getStatus());
+ assertEquals(3, conn.getCount());
+
+ assertEquals(1, payloadBuilder.getErrorsCount());
+ ErrorMetric err = payloadBuilder.getErrors(0);
+ assertEquals("42000", err.getErrorSqlState());
+ assertEquals("executeQuery", err.getMethodName());
+ assertEquals(2, err.getCount());
+
+ assertEquals(1, payloadBuilder.getFeatureUsagesCount());
+ FeatureUsage feat = payloadBuilder.getFeatureUsages(0);
+ assertEquals(DriverFeature.DRIVER_FEATURE_CUSTOM, feat.getDriverFeature());
+ assertEquals("CustomFeature", feat.getCustomFeatureName());
+ assertEquals(1, feat.getCount());
+ }
+
+ @Test
+ public void testCalculateBucket() {
+ int index1 = TelemetryBatcher.StatementAccumulator.calculateBucket(5); // < 50, index 0
+ assertEquals(0, index1);
+
+ int index2 = TelemetryBatcher.StatementAccumulator.calculateBucket(150); // < 250, index 2
+ assertEquals(2, index2);
+
+ int index3 = TelemetryBatcher.StatementAccumulator.calculateBucket(20000); // < 30000, index 9
+ assertEquals(9, index3);
+
+ int index4 =
+ TelemetryBatcher.StatementAccumulator.calculateBucket(
+ 4000000); // Overflow > 3600000 (1 hr), index 17
+ assertEquals(17, index4);
+ }
+
+ @Test
+ public void testOffer_flushesWhenQueueSizeThresholdReached() throws Exception {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ CountDownLatch flushed = new CountDownLatch(1);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ flushed.countDown();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setBatchSizeThreshold(3)
+ .setDriverEnvironment(driverEnvironment)
+ .build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ try (TelemetryBatcher batcher =
+ new TelemetryBatcher(config, transport, executorService, false)) {
+ // Three DISTINCT keys, so the queue reaches the threshold.
+ batcher.offer(featureNamed("a"));
+ batcher.offer(featureNamed("b"));
+ batcher.offer(featureNamed("c"));
+
+ assertTrue(flushed.await(5, TimeUnit.SECONDS));
+ assertEquals(1, requestCount.get());
+ }
+ }
+
+ @Test
+ public void testOffer_repeatedIdenticalMetricDoesNotFlush() throws Exception {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ CountDownLatch flushed = new CountDownLatch(1);
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ requestCount.incrementAndGet();
+ flushed.countDown();
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ return response;
+ }
+ };
+ }
+ };
+
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setBatchSizeThreshold(3)
+ .setDriverEnvironment(driverEnvironment)
+ .build();
+ ClearcutTransport transport = new ClearcutTransport(mockTransport, config);
+
+ try (TelemetryBatcher batcher =
+ new TelemetryBatcher(config, transport, executorService, false)) {
+ // 50 events, but ONE key, so the queue never grows past 1.
+ for (int i = 0; i < 50; i++) {
+ batcher.offer(featureNamed("same"));
+ }
+
+ // Assert inside the try block: close() flushes and would send a request.
+ assertFalse(flushed.await(500, TimeUnit.MILLISECONDS));
+ assertEquals(0, requestCount.get());
+ }
+ }
+
+ @Test
+ public void testComputeProfileCap_staysAboveThreshold() {
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder().setEnabled(true).setBatchSizeThreshold(10).build();
+ assertEquals(20, TelemetryBatcher.computeProfileCap(config));
+
+ // Degenerate inputs fall back rather than producing a cap at or below the threshold.
+ assertEquals(3000, TelemetryBatcher.computeProfileCap(null));
+ TelemetryConfiguration zero =
+ TelemetryConfiguration.newBuilder().setEnabled(true).setBatchSizeThreshold(0).build();
+ assertEquals(3000, TelemetryBatcher.computeProfileCap(zero));
+
+ // No overflow to a negative cap.
+ TelemetryConfiguration huge =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setBatchSizeThreshold(Integer.MAX_VALUE)
+ .build();
+ assertTrue(TelemetryBatcher.computeProfileCap(huge) > 0);
+ }
+
+ @Test
+ public void testClose_doesNotWaitForUnresponsiveEndpoint() throws IOException {
+ try (ServerSocket unresponsive = new ServerSocket(0); ) {
+ TelemetryConfiguration configuration =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setEndpointUrl("http://127.0.0.1:" + unresponsive.getLocalPort())
+ .setDriverEnvironment(driverEnvironment)
+ .build();
+ TelemetryBatcher batcher =
+ new TelemetryBatcher(
+ configuration, new ClearcutTransport(configuration), executorService, true);
+ batcher.offer(featureNamed("testFeature"));
+
+ long start = System.currentTimeMillis();
+ batcher.close();
+ long end = System.currentTimeMillis();
+ long elapsedMs = end - start;
+
+ assertTrue(elapsedMs < 5000, "close took " + elapsedMs + " ms");
+ assertTrue(executorService.isShutdown());
+ }
+ }
+
+ @Test
+ public void testFailedFlush_requeueRespectsProfileCap() throws Exception {
+ AtomicReference batcherRef = new AtomicReference<>();
+ AtomicInteger requestCount = new AtomicInteger();
+ AtomicReference retriedBody = new AtomicReference<>();
+ MockHttpTransport mockTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ return new MockLowLevelHttpRequest(url) {
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ if (requestCount.incrementAndGet() == 1) {
+ // While the first send is in flight, fill the live map up to the cap (4).
+ TelemetryBatcher batcher = batcherRef.get();
+ batcher.offer(featureNamed("a"));
+ batcher.offer(featureNamed("x"));
+ batcher.offer(featureNamed("y"));
+ batcher.offer(featureNamed("z"));
+ response.setStatusCode(500);
+ return response;
+ }
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ getStreamingContent().writeTo(out);
+ retriedBody.set(out.toByteArray());
+ response.setStatusCode(200);
+ return response;
+ }
+ };
+ }
+ };
+ // Threshold 2 gives a profile cap of 4.
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(true)
+ .setBatchSizeThreshold(2)
+ .setDriverEnvironment(driverEnvironment)
+ .build();
+
+ // No executor: flushes run on the test thread, so the ordering is deterministic.
+ try (TelemetryBatcher batcher =
+ new TelemetryBatcher(config, new ClearcutTransport(mockTransport, config), null, false)) {
+ batcherRef.set(batcher);
+ batcher.offer(featureNamed("a"));
+ batcher.offer(featureNamed("b"));
+ batcher.offer(featureNamed("c"));
+
+ assertFalse(batcher.flush().isSuccess());
+ assertTrue(batcher.flush().isSuccess());
+ }
+
+ TelemetryPayload payload =
+ TelemetryPayload.parseFrom(
+ LogRequest.parseFrom(retriedBody.get()).getLogEvents(0).getSourceExtension());
+ Map counts =
+ payload.getFeatureUsagesList().stream()
+ .collect(
+ Collectors.toMap(FeatureUsage::getCustomFeatureName, f -> (long) f.getCount()));
+
+ assertEquals(4, counts.size());
+ assertEquals(2L, counts.get("a")); // existing key: the failed count is merged in
+ assertFalse(counts.containsKey("b")); // new keys are dropped once the live map is at the cap
+ assertFalse(counts.containsKey("c"));
+ }
+
+ private static Message featureNamed(String name) {
+ return FeatureUsage.newBuilder()
+ .setDriverFeature(DriverFeature.DRIVER_FEATURE_CUSTOM)
+ .setCustomFeatureName(name)
+ .build();
+ }
+}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryConfigurationTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryConfigurationTest.java
new file mode 100644
index 000000000000..2293afc815c7
--- /dev/null
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryConfigurationTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class TelemetryConfigurationTest {
+
+ @Test
+ public void testDefaultValues() {
+ TelemetryConfiguration config = TelemetryConfiguration.newBuilder().build();
+
+ assertTrue(config.isEnabled());
+ assertEquals(3071, config.getLogSource());
+ assertEquals("https://play.googleapis.com/log", config.getEndpointUrl());
+ assertEquals(300_000L, config.getUploadIntervalMs());
+ assertEquals(5000, config.getBatchSizeThreshold());
+ assertNull(config.getDriverEnvironment());
+ }
+
+ @Test
+ public void testCustomValues() {
+ DriverEnvironment environment =
+ DriverEnvironment.newBuilder()
+ .setDriverName("BigQuery JDBC")
+ .setDriverVersion("1.0.0")
+ .build();
+
+ TelemetryConfiguration config =
+ TelemetryConfiguration.newBuilder()
+ .setEnabled(false)
+ .setLogSource(1234)
+ .setEndpointUrl("https://custom.endpoint.com/log")
+ .setUploadIntervalMs(60_000L)
+ .setBatchSizeThreshold(50)
+ .setDriverEnvironment(environment)
+ .build();
+
+ assertFalse(config.isEnabled());
+ assertEquals(1234, config.getLogSource());
+ assertEquals("https://custom.endpoint.com/log", config.getEndpointUrl());
+ assertEquals(60_000L, config.getUploadIntervalMs());
+ assertEquals(50, config.getBatchSizeThreshold());
+ assertEquals(environment, config.getDriverEnvironment());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ TelemetryConfiguration config1 =
+ TelemetryConfiguration.newBuilder().setEnabled(true).setLogSource(1234).build();
+
+ TelemetryConfiguration config2 =
+ TelemetryConfiguration.newBuilder().setEnabled(true).setLogSource(1234).build();
+
+ TelemetryConfiguration config3 =
+ TelemetryConfiguration.newBuilder().setEnabled(false).setLogSource(1234).build();
+
+ assertEquals(config1, config2);
+ assertEquals(config1.hashCode(), config2.hashCode());
+ assertNotEquals(config1, config3);
+ assertNotEquals(config1, null);
+ assertNotEquals(config1, "string");
+ }
+}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManagerTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManagerTest.java
new file mode 100644
index 000000000000..2754821edd7e
--- /dev/null
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManagerTest.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.cloud.bigquery.JobStatistics.QueryStatistics;
+import java.sql.SQLException;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TelemetryManagerTest {
+
+ @BeforeEach
+ @AfterEach
+ public void cleanUp() {
+ TelemetryTestUtils.reset();
+ }
+
+ @Test
+ public void testGetInstance_returnsSameSingletonInstance() {
+ TelemetryManager manager1 = TelemetryManager.getInstance();
+ TelemetryManager manager2 = TelemetryManager.getInstance();
+
+ assertNotNull(manager1);
+ assertSame(manager1, manager2);
+ assertTrue(TelemetryManager.isInitialized());
+ assertNotNull(manager1.getBatcher());
+ }
+
+ @Test
+ public void testInit_withDisabledConfig_doesNotInitializeInstance() {
+ TelemetryConfiguration config = TelemetryConfiguration.builder().setEnabled(false).build();
+ ClearcutTransport transport = new ClearcutTransport(config);
+
+ TelemetryManager.init(config, transport);
+
+ assertFalse(TelemetryManager.isInitialized());
+ }
+
+ @Test
+ public void testInit_withEnabledConfig_initializesCustomInstance() {
+ TelemetryConfiguration config = TelemetryConfiguration.builder().setEnabled(true).build();
+ ClearcutTransport transport = new ClearcutTransport(config);
+
+ TelemetryManager.init(config, transport);
+
+ assertTrue(TelemetryManager.isInitialized());
+ assertNotNull(TelemetryManager.getInstance().getBatcher());
+ }
+
+ @Test
+ public void testCloseInstance_resetsSingletonState() {
+ TelemetryManager.getInstance();
+ assertTrue(TelemetryManager.isInitialized());
+
+ TelemetryManager.closeInstance();
+ assertFalse(TelemetryManager.isInitialized());
+ }
+
+ @Test
+ public void testRunSafely_executesRunnableSuccessfully() {
+ AtomicBoolean executed = new AtomicBoolean(false);
+
+ TelemetryManager.runSafely(() -> executed.set(true));
+
+ assertTrue(executed.get());
+ }
+
+ @Test
+ public void testRunSafely_swallowsExceptionsWithoutThrowing() {
+ AtomicBoolean lineAfterException = new AtomicBoolean(false);
+
+ TelemetryManager.runSafely(
+ () -> {
+ throw new RuntimeException("Simulated telemetry exception");
+ });
+ lineAfterException.set(true);
+
+ assertTrue(lineAfterException.get());
+ }
+
+ @Test
+ public void testRunSafely_handlesNullActionGracefully() {
+ TelemetryManager.runSafely(null);
+ // Verifies no NullPointerException is thrown
+ }
+
+ @Test
+ public void testToStatementType() {
+ assertEquals(
+ StatementType.STATEMENT_TYPE_SELECT,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.SELECT));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_INSERT,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.INSERT));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_UPDATE,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.UPDATE));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_DELETE,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.DELETE));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_MERGE,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.MERGE));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_CREATE_TABLE,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.CREATE_TABLE));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_CREATE_MODEL,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.CREATE_MODEL));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_CREATE_VIEW,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.CREATE_VIEW));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_DROP_TABLE,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.DROP_TABLE));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_DROP_VIEW,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.DROP_VIEW));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_ALTER_TABLE,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.ALTER_TABLE));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_ALTER_VIEW,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.ALTER_VIEW));
+ assertEquals(
+ StatementType.STATEMENT_TYPE_SCRIPT,
+ TelemetryManager.toStatementType(QueryStatistics.StatementType.SCRIPT));
+
+ assertEquals(StatementType.STATEMENT_TYPE_UNSPECIFIED, TelemetryManager.toStatementType(null));
+ }
+
+ @Test
+ public void testToAuthenticationType() {
+ assertEquals(
+ AuthenticationType.AUTHENTICATION_TYPE_SERVICE_ACCOUNT,
+ TelemetryManager.toAuthenticationType(0));
+ assertEquals(
+ AuthenticationType.AUTHENTICATION_TYPE_USER_AUTHENTICATION,
+ TelemetryManager.toAuthenticationType(1));
+ assertEquals(
+ AuthenticationType.AUTHENTICATION_TYPE_TOKEN, TelemetryManager.toAuthenticationType(2));
+ assertEquals(
+ AuthenticationType.AUTHENTICATION_TYPE_APPLICATION_DEFAULT_CREDENTIALS,
+ TelemetryManager.toAuthenticationType(3));
+ assertEquals(
+ AuthenticationType.AUTHENTICATION_TYPE_EXTERNAL, TelemetryManager.toAuthenticationType(4));
+
+ assertEquals(
+ AuthenticationType.AUTHENTICATION_TYPE_CUSTOM, TelemetryManager.toAuthenticationType(5));
+ assertEquals(
+ AuthenticationType.AUTHENTICATION_TYPE_CUSTOM, TelemetryManager.toAuthenticationType(100));
+ }
+
+ @Test
+ public void testOptOutConfiguration() {
+ Properties props = new Properties();
+
+ // Default is true
+ assertTrue(TelemetryConfiguration.builder().resolveProperties(props).build().isEnabled());
+
+ // Disabled via lowercase
+ props.setProperty("EnableDiagnosticTelemetry", "false");
+ assertFalse(TelemetryConfiguration.builder().resolveProperties(props).build().isEnabled());
+
+ // Disabled via uppercase and "0"
+ props.clear();
+ props.setProperty("EnableDiagnosticTelemetry", "0");
+ assertFalse(TelemetryConfiguration.builder().resolveProperties(props).build().isEnabled());
+
+ // Enabled via "1"
+ props.clear();
+ props.setProperty("EnableDiagnosticTelemetry", "1");
+ assertTrue(TelemetryConfiguration.builder().resolveProperties(props).build().isEnabled());
+ }
+
+ @Test
+ public void testGlobalKillSwitch() {
+ // Turn it on
+ Properties props1 = new Properties();
+ props1.setProperty("EnableDiagnosticTelemetry", "1");
+ TelemetryManager mgr1 = TelemetryManager.getInstance(props1);
+ assertNotNull(mgr1);
+
+ // Any connection passes '0', it gets permanently killed
+ Properties props2 = new Properties();
+ props2.setProperty("EnableDiagnosticTelemetry", "0");
+ TelemetryManager mgr2 = TelemetryManager.getInstance(props2);
+
+ // The manager should be null and killed globally
+ assertNull(mgr2);
+ assertNull(TelemetryManager.getInstance());
+ assertNull(TelemetryManager.getInstance(props1));
+ }
+
+ @Test
+ public void testGetInstance_populatesDriverEnvironment() {
+ TelemetryManager manager = TelemetryManager.getInstance();
+
+ DriverEnvironment environment = manager.getBatcher().getConfig().getDriverEnvironment();
+ assertNotNull(environment);
+ assertEquals(DriverEnvironmentDetector.DRIVER_NAME, environment.getDriverName());
+ assertEquals(DriverEnvironmentDetector.CLIENT_LANGUAGE, environment.getClientLanguage());
+ assertFalse(environment.getTelemetryTag().isEmpty());
+ }
+
+ @Test
+ public void testExtractSqlState() {
+ assertEquals(
+ "42000", TelemetryManager.extractSqlState(new SQLException("bad syntax", "42000")));
+ assertEquals("HY000", TelemetryManager.extractSqlState(new SQLException("general", "HY000")));
+ }
+
+ @Test
+ public void testExtractSqlState_walksCauseChain() {
+ SQLException root = new SQLException("overflow", "22003");
+ Throwable wrapped = new RuntimeException(new IllegalStateException(root));
+ assertEquals("22003", TelemetryManager.extractSqlState(wrapped));
+ }
+
+ @Test
+ public void testExtractXdbcCode_noSqlStateReturnsEmptyString() {
+ assertEquals("", TelemetryManager.extractSqlState(null));
+ assertEquals("", TelemetryManager.extractSqlState(new RuntimeException("no state")));
+ assertEquals("", TelemetryManager.extractSqlState(new SQLException("none", (String) null)));
+ }
+}
diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryTestUtils.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryTestUtils.java
new file mode 100644
index 000000000000..871f03568020
--- /dev/null
+++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryTestUtils.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 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.bigquery.jdbc.telemetry.v1;
+
+/** Test-only access to {@link TelemetryManager}'s package-private lifecycle state. */
+public final class TelemetryTestUtils {
+ private TelemetryTestUtils() {}
+
+ /** Closes the shared instance and clears the global opt-out kill switch. */
+ public static void reset() {
+ TelemetryManager.closeInstance();
+ TelemetryManager.resetGlobalDisableForTest();
+ }
+}
diff --git a/java-bigquery/README.md b/java-bigquery/README.md
index c14f20f4a67b..3b439603855d 100644
--- a/java-bigquery/README.md
+++ b/java-bigquery/README.md
@@ -45,7 +45,7 @@ If you are using Maven without the BOM, add this to your dependencies:
com.google.cloud
google-cloud-bigquery
- 2.73.0
+ 2.74.0
```
@@ -53,20 +53,20 @@ If you are using Maven without the BOM, add this to your dependencies:
If you are using Gradle 5.x or later, add this to your dependencies:
```Groovy
-implementation platform('com.google.cloud:libraries-bom:2.73.0')
+implementation platform('com.google.cloud:libraries-bom:2.74.0')
implementation 'com.google.cloud:google-cloud-bigquery'
```
If you are using Gradle without BOM, add this to your dependencies:
```Groovy
-implementation 'com.google.cloud:google-cloud-bigquery:2.73.0'
+implementation 'com.google.cloud:google-cloud-bigquery:2.74.0'
```
If you are using SBT, add this to your dependencies:
```Scala
-libraryDependencies += "com.google.cloud" % "google-cloud-bigquery" % "2.73.0"
+libraryDependencies += "com.google.cloud" % "google-cloud-bigquery" % "2.74.0"
```
diff --git a/java-bigquery/benchmark/pom.xml b/java-bigquery/benchmark/pom.xml
index 7a040e11bc48..2f59f4a6de04 100644
--- a/java-bigquery/benchmark/pom.xml
+++ b/java-bigquery/benchmark/pom.xml
@@ -4,11 +4,11 @@
4.0.0
com.google.cloud
benchmark
- 2.74.0-SNAPSHOT
+ 2.74.0
google-cloud-bigquery-parent
com.google.cloud
- 2.74.0-SNAPSHOT
+ 2.74.0
diff --git a/java-bigquery/google-cloud-bigquery-bom/pom.xml b/java-bigquery/google-cloud-bigquery-bom/pom.xml
index fa34b8ef529d..65f97fef3b5b 100644
--- a/java-bigquery/google-cloud-bigquery-bom/pom.xml
+++ b/java-bigquery/google-cloud-bigquery-bom/pom.xml
@@ -3,12 +3,12 @@
4.0.0
com.google.cloud
google-cloud-bigquery-bom
- 2.74.0-SNAPSHOT
+ 2.74.0
pom
com.google.cloud
google-cloud-pom-parent
- 1.94.0-SNAPSHOT
+ 1.94.0
../../google-cloud-pom-parent/pom.xml
@@ -53,7 +53,7 @@
com.google.cloud
google-cloud-bigquery
- 2.74.0-SNAPSHOT
+ 2.74.0