diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransport.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransport.java new file mode 100644 index 000000000000..5680516cab64 --- /dev/null +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransport.java @@ -0,0 +1,126 @@ +/* + * 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.api.client.http.ByteArrayContent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.cloud.bigquery.jdbc.BigQueryJdbcCustomLogger; +import java.io.IOException; +import java.io.InputStream; +import java.util.logging.Level; +import java.util.logging.Logger; + +final class ClearcutTransport { + private static final Logger logger = + new BigQueryJdbcCustomLogger(ClearcutTransport.class.getName()); + private static final String CONTENT_TYPE_PROTOBUF = "application/x-protobuf"; + private static final int DEFAULT_CONNECT_TIMEOUT_MS = 10_000; + private static final int DEFAULT_READ_TIMEOUT_MS = 10_000; + + private final HttpTransport httpTransport; + private final TelemetryConfiguration config; + private final HttpRequestFactory requestFactory; + + ClearcutTransport(TelemetryConfiguration config) { + this(new NetHttpTransport(), config); + } + + // Package-private constructor for testing overrides + ClearcutTransport(HttpTransport httpTransport, TelemetryConfiguration config) { + this.httpTransport = httpTransport; + this.config = config; + this.requestFactory = this.httpTransport.createRequestFactory(); + } + + TransportResult send(TelemetryPayload payload) { + if (!config.isEnabled()) { + return TransportResult.disabled(); + } + if (payload == null) { + logger.log(Level.WARNING, "Cannot send null telemetry payload to Clearcut"); + return TransportResult.disabled(); + } + + long now = System.currentTimeMillis(); + LogRequest logRequest = + LogRequest.newBuilder() + .setLogSource(config.getLogSource()) + .setRequestTimeMs(now) + .addLogEvents( + LogEvent.newBuilder() + .setEventTimeMs(now) + .setSourceExtension(payload.toByteString()) + .build()) + .build(); + + byte[] requestBytes = logRequest.toByteArray(); + HttpContent content = new ByteArrayContent(CONTENT_TYPE_PROTOBUF, requestBytes); + GenericUrl url = new GenericUrl(config.getEndpointUrl()); + + long nextRequestWaitMillis = -1; + + try { + HttpRequest request = requestFactory.buildPostRequest(url, content); + request.setConnectTimeout(DEFAULT_CONNECT_TIMEOUT_MS); + request.setReadTimeout(DEFAULT_READ_TIMEOUT_MS); + request.setThrowExceptionOnExecuteError(false); + + HttpResponse response = null; + try { + response = request.execute(); + int statusCode = response.getStatusCode(); + + if (response.getContent() != null) { + try (InputStream is = response.getContent()) { + LogResponse logResponse = LogResponse.parseFrom(is); + if (logResponse.getNextRequestWaitMillis() > 0) { + nextRequestWaitMillis = logResponse.getNextRequestWaitMillis(); + } + } catch (IOException ignored) { + // Ignore non-protobuf content from error bodies + } + } + + boolean success = statusCode >= 200 && statusCode < 300; + if (success) { + logger.log(Level.FINE, "Successfully uploaded telemetry payload to Clearcut"); + } else { + logger.log( + Level.WARNING, + String.format("Clearcut upload failed with status code: %d", statusCode)); + } + return new TransportResult(success, nextRequestWaitMillis); + } finally { + if (response != null) { + response.disconnect(); + } + } + } catch (IOException e) { + logger.log(Level.WARNING, "IOException sending telemetry payload to Clearcut", e); + return new TransportResult(false, nextRequestWaitMillis); + } catch (Throwable t) { + logger.log(Level.WARNING, "Unexpected error sending telemetry payload to Clearcut", t); + return new TransportResult(false, nextRequestWaitMillis); + } + } +} 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/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..26b078c91a8e --- /dev/null +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java @@ -0,0 +1,211 @@ +/* + * 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.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +public class ClearcutTransportTest { + + @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()); + } +}