diff --git a/memorystore/redis/client_side_metrics/pom.xml b/memorystore/redis/client_side_metrics/pom.xml new file mode 100644 index 00000000000..876aa034f65 --- /dev/null +++ b/memorystore/redis/client_side_metrics/pom.xml @@ -0,0 +1,100 @@ + + + + 4.0.0 + + com.example.memorystore + redis-client-side-metrics + 1.0-SNAPSHOT + + + 1.8 + 1.8 + UTF-8 + + + + + + redis.clients + jedis + 5.1.0 + + + io.opentelemetry + opentelemetry-api + 1.36.0 + + + io.opentelemetry + opentelemetry-sdk + 1.36.0 + + + com.google.cloud.opentelemetry + exporter-trace + 0.28.0 + + + com.google.cloud.opentelemetry + exporter-metrics + 0.28.0 + + + + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-core + 4.11.0 + test + + + org.slf4j + slf4j-simple + 1.7.36 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.12.1 + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + \ No newline at end of file diff --git a/memorystore/redis/client_side_metrics/src/main/java/com/example/memorystore/redis/RedisTelemetryApp.java b/memorystore/redis/client_side_metrics/src/main/java/com/example/memorystore/redis/RedisTelemetryApp.java new file mode 100644 index 00000000000..b9de30e53ac --- /dev/null +++ b/memorystore/redis/client_side_metrics/src/main/java/com/example/memorystore/redis/RedisTelemetryApp.java @@ -0,0 +1,290 @@ +/* + * 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.example.memorystore.redis; + +// [START memorystore_redis_client_side_metrics] +import com.google.cloud.opentelemetry.metric.GoogleCloudMetricExporter; +import com.google.cloud.opentelemetry.trace.TraceExporter; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.exceptions.JedisConnectionException; + +import java.time.Duration; +import java.util.function.Function; + +/** + * Sample application demonstrating client-side metrics and tracing for + * Google Cloud Memorystore for Redis. + */ +public final class RedisTelemetryApp { + /** Attribute key for Redis operation names. */ + private static final AttributeKey ATTR_OPERATION = + AttributeKey.stringKey("operation"); + + /** Maximum number of Redis reconnection attempts. */ + private static final int MAX_RETRIES = 3; + + /** Maximum total connections for the Jedis pool. */ + private static final int POOL_MAX_TOTAL = 20; + + /** Interval in seconds for exporting metrics to Google Cloud. */ + private static final long METRIC_INTERVAL_SECONDS = 10L; + + /** Base multiplier for exponential backoff sleep (in milliseconds). */ + private static final long RETRY_BACKOFF_BASE_MS = 100L; + + /** Conversion factor from Nanoseconds to Milliseconds. */ + private static final double NANO_TO_MS = 1_000_000.0; + + /** Default Redis port. */ + private static final int DEFAULT_REDIS_PORT = 6379; + + /** OpenTelemetry Tracer instance for recording trace spans. */ + private static Tracer tracer; + + /** OpenTelemetry Histogram for Redis round-trip time. */ + private static DoubleHistogram rttHist; + + /** OpenTelemetry Histogram for pool blocking latency. */ + private static DoubleHistogram clientBlockHist; + + /** OpenTelemetry Histogram for application logic blocking latency. */ + private static DoubleHistogram appBlockHist; + + /** OpenTelemetry Counter for Redis reconnection retry events. */ + private static LongCounter retryCounter; + + /** OpenTelemetry Counter for Redis connectivity errors. */ + private static LongCounter connErrorCounter; + + /** Shared Jedis connection pool. */ + private static JedisPool jedisPool; + + /** + * Private constructor to prevent instantiation of this utility class. + */ + private RedisTelemetryApp() { + } + + /** + * Main entry point for running the sample application. + * + * @param args Command line arguments (not used). + */ + public static void main(final String[] args) { + setupTelemetry(); + + final String host = System.getenv() + .getOrDefault("REDISHOST", "localhost"); + final int port = Integer.parseInt(System.getenv() + .getOrDefault("REDISPORT", + String.valueOf(DEFAULT_REDIS_PORT))); + + final JedisPoolConfig poolConfig = new JedisPoolConfig(); + poolConfig.setMaxTotal(POOL_MAX_TOTAL); + poolConfig.setBlockWhenExhausted(true); + jedisPool = new JedisPool(poolConfig, host, port); + + try { + run(); + } finally { + if (jedisPool != null) { + jedisPool.close(); + } + } + } + + /** + * Executes the core business logic of reading and writing to Redis. + * + * @return The string retrieved from the Redis 'get' operation. + */ + static String run() { + final Span span = tracer.spanBuilder("process_user_span") + .startSpan(); + try { + smartRedisCall("set_user", jedis -> + jedis.set("user:123", "active")); + + final String result = smartRedisCall("get_user", jedis -> + jedis.get("user:123")); + System.out.println("Retrieved: " + result); + return result; + } catch (Exception e) { + span.recordException(e); + throw e; + } finally { + span.end(); + } + } + + /** + * Injects mocked or no-op telemetry and pool instances for unit testing. + * + * @param pool The mocked or test JedisPool instance. + * @param testOpenTelemetry The OpenTelemetry instance to use for testing. + */ + static void initForTest( + final JedisPool pool, + final OpenTelemetry testOpenTelemetry) { + jedisPool = pool; + tracer = testOpenTelemetry.getTracer("jedis.client"); + final Meter meter = testOpenTelemetry.getMeter("jedis.metrics"); + + rttHist = meter.histogramBuilder("redis_client_rtt") + .setUnit("ms").build(); + clientBlockHist = meter + .histogramBuilder("redis_client_blocking_latency") + .setUnit("ms").build(); + appBlockHist = meter + .histogramBuilder("redis_application_blocking_latency") + .setUnit("ms").build(); + retryCounter = meter.counterBuilder("redis_retry_count").build(); + connErrorCounter = meter + .counterBuilder("redis_connectivity_error_count") + .build(); + + retryCounter.add(0, Attributes.of(ATTR_OPERATION, "startup")); + connErrorCounter.add(0, Attributes.of(ATTR_OPERATION, "startup")); + } + + /** + * Configures the production OpenTelemetry SDK to export Traces and Metrics + * to Google Cloud Operations. + */ + private static void setupTelemetry() { + final SpanExporter traceExporter = + TraceExporter.createWithDefaultConfiguration(); + final SdkTracerProvider tracerProvider = + SdkTracerProvider.builder() + .addSpanProcessor( + BatchSpanProcessor.builder(traceExporter) + .build()) + .build(); + + final MetricExporter metricExporter = + GoogleCloudMetricExporter.createWithDefaultConfiguration(); + final SdkMeterProvider meterProvider = + SdkMeterProvider.builder() + .registerMetricReader( + PeriodicMetricReader.builder(metricExporter) + .setInterval(Duration.ofSeconds( + METRIC_INTERVAL_SECONDS)) + .build()) + .build(); + + final OpenTelemetry openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setMeterProvider(meterProvider) + .buildAndRegisterGlobal(); + + tracer = openTelemetry.getTracer("jedis.client"); + final Meter meter = openTelemetry.getMeter("jedis.metrics"); + + rttHist = meter.histogramBuilder("redis_client_rtt") + .setUnit("ms").build(); + clientBlockHist = meter + .histogramBuilder("redis_client_blocking_latency") + .setUnit("ms").build(); + appBlockHist = meter + .histogramBuilder("redis_application_blocking_latency") + .setUnit("ms").build(); + retryCounter = meter.counterBuilder("redis_retry_count").build(); + connErrorCounter = meter + .counterBuilder("redis_connectivity_error_count") + .build(); + + retryCounter.add(0, Attributes.of(ATTR_OPERATION, "startup")); + connErrorCounter.add(0, Attributes.of(ATTR_OPERATION, "startup")); + } + + /** + * Wraps a Redis operation with latency metrics, reconnection retry logic, + * and trace spans. + * + * @param The return type of the Redis operation. + * @param operationName The name of the operation for metric attributes. + * @param operation The Redis command lambda to execute safely. + * @return The return value from the Redis command. + */ + private static T smartRedisCall( + final String operationName, + final Function operation) { + int attempt = 0; + final Attributes attrs = Attributes.of(ATTR_OPERATION, + operationName); + + final Span span = tracer.spanBuilder(operationName).startSpan(); + + try { + while (attempt < MAX_RETRIES) { + final long poolStart = System.nanoTime(); + try (Jedis jedis = jedisPool.getResource()) { + clientBlockHist.record((System.nanoTime() - poolStart) + / NANO_TO_MS, attrs); + + final long reqStart = System.nanoTime(); + final T response = operation.apply(jedis); + rttHist.record((System.nanoTime() - reqStart) + / NANO_TO_MS, attrs); + + final long appStart = System.nanoTime(); + @SuppressWarnings("unused") + final String dummy = String.valueOf(response); + appBlockHist.record((System.nanoTime() - appStart) + / NANO_TO_MS, attrs); + + return response; + } catch (JedisConnectionException e) { + attempt++; + connErrorCounter.add(1, attrs); + retryCounter.add(1, attrs); + span.recordException(e); + if (attempt >= MAX_RETRIES) { + throw e; + } + try { + Thread.sleep((long) (Math.pow(2, attempt) + * RETRY_BACKOFF_BASE_MS)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + } + return null; + } finally { + span.end(); + } + } +} +// [END memorystore_redis_client_side_metrics] diff --git a/memorystore/redis/client_side_metrics/src/main/java/com/example/memorystore/redis/package-info.java b/memorystore/redis/client_side_metrics/src/main/java/com/example/memorystore/redis/package-info.java new file mode 100644 index 00000000000..55ed524cf92 --- /dev/null +++ b/memorystore/redis/client_side_metrics/src/main/java/com/example/memorystore/redis/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Memorystore Redis Client-Side Metrics sample package. + */ +package com.example.memorystore.redis; diff --git a/memorystore/redis/client_side_metrics/src/test/java/com/example/memorystore/redis/RedisTelemetryAppTest.java b/memorystore/redis/client_side_metrics/src/test/java/com/example/memorystore/redis/RedisTelemetryAppTest.java new file mode 100644 index 00000000000..a066d9a946f --- /dev/null +++ b/memorystore/redis/client_side_metrics/src/test/java/com/example/memorystore/redis/RedisTelemetryAppTest.java @@ -0,0 +1,110 @@ +/* + * 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.example.memorystore.redis; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.OpenTelemetry; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.exceptions.JedisConnectionException; + +/** Unit tests for {@link RedisTelemetryApp} verifying Redis interaction and Retry logic. */ +@RunWith(JUnit4.class) +public class RedisTelemetryAppTest { + + private JedisPool mockJedisPool; + private Jedis mockJedis; + + @Before + public void setUp() { + mockJedisPool = mock(JedisPool.class); + mockJedis = mock(Jedis.class); + + // Inject the mocked pool and a NO-OP OpenTelemetry instance so tests + // run hermetically without contacting real GCP Trace/Metric APIs. + RedisTelemetryApp.initForTest(mockJedisPool, OpenTelemetry.noop()); + + // Configure the mocked pool to hand out our mocked Jedis connection + when(mockJedisPool.getResource()).thenReturn(mockJedis); + + // Configure expected mocked Redis responses + when(mockJedis.set(eq("user:123"), eq("active"))).thenReturn("OK"); + when(mockJedis.get(eq("user:123"))).thenReturn("active"); + } + + @Test + public void testRun_successfulExecution() { + // Execute the extracted sample logic + String result = RedisTelemetryApp.run(); + + // Verify the application correctly read the final state + assertNotNull("Result should not be null", result); + assertEquals("active", result); + + // Verify the Redis pool handed out a connection, and the sample called set & get + verify(mockJedisPool, atLeastOnce()).getResource(); + verify(mockJedis, times(1)).set("user:123", "active"); + verify(mockJedis, times(1)).get("user:123"); + verify(mockJedis, atLeastOnce()).close(); // try-with-resources closes connection + } + + @Test + public void testRun_retryMechanismOnConnectivityFailure() { + // Configure the mocked pool to throw a Connection Exception on the FIRST call, + // and return the valid mock connection on the SECOND call. + when(mockJedisPool.getResource()) + .thenThrow(new JedisConnectionException("Temporary network blip")) + .thenReturn(mockJedis); + + String result = RedisTelemetryApp.run(); + + // Verify it still succeeds due to the retry handler in smartRedisCall + assertEquals("active", result); + + // Verify getResource was called 3 times (1 failed + 1 successful for 'set', and 1 successful for 'get') + verify(mockJedisPool, times(3)).getResource(); + verify(mockJedis, times(1)).set("user:123", "active"); + verify(mockJedis, times(1)).get("user:123"); + } + + @Test + public void testRun_failureAfterMaxRetries() { + // Configure the mocked pool to fail CONSTANTLY + when(mockJedisPool.getResource()) + .thenThrow(new JedisConnectionException("Redis is down permanently")); + + // Verify it stops retrying and propagates the exception upward to alert CI + assertThrows(JedisConnectionException.class, RedisTelemetryApp::run); + + // Verify it attempted exactly 3 retries before quitting + verify(mockJedisPool, times(3)).getResource(); + } +} \ No newline at end of file