From a42bc734ea25ef879ce0d4e5295763ca8ff9eb0f Mon Sep 17 00:00:00 2001 From: "Greg L. Turnquist" Date: Thu, 20 Oct 2022 18:40:21 -0500 Subject: [PATCH] Provide better support for activating observability with Spring Data Cassandra. Resolves #1321. Original pull request: #1322 --- .../observability/CassandraObservation.java | 2 +- .../CassandraObservationConfiguration.java | 52 +++++++++++++ .../CqlSessionTracingInterceptor.java | 15 +--- ...efaultCassandraObservationConvention.java} | 6 +- .../EnableCassandraObservability.java | 34 +++++++++ ...lSessionTracingBeanPostProcessorTests.java | 34 +++++++-- .../observability/CqlSessionTracingTests.java | 8 +- .../observability/ZipkinIntegrationTests.java | 11 +-- .../asciidoc/reference/observability.adoc | 74 +++++++++++++++++++ 9 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservationConfiguration.java rename spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/{DefaultCassandraObservationContention.java => DefaultCassandraObservationConvention.java} (92%) create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/EnableCassandraObservability.java diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservation.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservation.java index 0d15c1770..7db40d305 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservation.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservation.java @@ -19,7 +19,7 @@ import io.micrometer.common.docs.KeyName; import io.micrometer.observation.docs.ObservationDocumentation; /** - * Cassandra-based implementation of {@link DocumentedObservation}. + * Cassandra-based implementation of {@link ObservationDocumentation}. * * @author Mark Paluch * @author Marcin Grzejszczak diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservationConfiguration.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservationConfiguration.java new file mode 100644 index 000000000..e0723b510 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CassandraObservationConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * 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 org.springframework.data.cassandra.observability; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.tracing.Tracer; + +import java.util.Optional; + +import org.springframework.context.annotation.Bean; + +/** + * Set of beans enable observability with Spring Data Cassandra. + * + * @author Greg Turnquist + * @since 4.0.0 + */ +public class CassandraObservationConfiguration { + + @Bean + CqlSessionObservationConvention observationConvention() { + return new DefaultCassandraObservationConvention(); + } + + @Bean + CqlSessionTracingObservationHandler cqlSessionTracingObservationHandler( + Optional observationRegistry, Tracer tracer) { + + CqlSessionTracingObservationHandler observationHandler = new CqlSessionTracingObservationHandler(tracer); + observationRegistry.ifPresent(registry -> registry.observationConfig().observationHandler(observationHandler)); + return observationHandler; + } + + @Bean + CqlSessionTracingBeanPostProcessor traceCqlSessionBeanPostProcessor(ObservationRegistry observationRegistry, + CqlSessionObservationConvention observationConvention) { + return new CqlSessionTracingBeanPostProcessor(observationRegistry, observationConvention); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CqlSessionTracingInterceptor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CqlSessionTracingInterceptor.java index 62e9eb4a9..d6c1f4462 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CqlSessionTracingInterceptor.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/CqlSessionTracingInterceptor.java @@ -102,7 +102,7 @@ final class CqlSessionTracingInterceptor implements MethodInterceptor { Function, Object> statementExecutor) { if (this.observationRegistry.getCurrentObservation() == null) { - return null; + return statementExecutor.apply(statement); } Observation observation = childObservation(statement, methodName, this.delegateSession); @@ -111,14 +111,7 @@ final class CqlSessionTracingInterceptor implements MethodInterceptor { log.debug("Created a new child observation before query [" + observation + "]"); } - try (Observation.Scope scope = observation.openScope()) { - return statementExecutor.apply(statement); - } catch (Exception e) { - observation.error(e); - throw e; - } finally { - observation.stop(); - } + return observation.observe(() -> statementExecutor.apply(statement)); } /** @@ -154,7 +147,7 @@ final class CqlSessionTracingInterceptor implements MethodInterceptor { .observation(this.observationRegistry, () -> observationContext) // .contextualName(CassandraObservation.CASSANDRA_QUERY_OBSERVATION.getContextualName()) // .highCardinalityKeyValues(this.observationConvention.getHighCardinalityKeyValues(observationContext)) // - .lowCardinalityKeyValues(this.observationConvention.getLowCardinalityKeyValues(observationContext)) // - .start(); + .lowCardinalityKeyValues(this.observationConvention.getLowCardinalityKeyValues(observationContext)); + // .start(); } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/DefaultCassandraObservationContention.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/DefaultCassandraObservationConvention.java similarity index 92% rename from spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/DefaultCassandraObservationContention.java rename to spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/DefaultCassandraObservationConvention.java index 2f31c56f9..43919353b 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/DefaultCassandraObservationContention.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/DefaultCassandraObservationConvention.java @@ -32,7 +32,7 @@ import com.datastax.oss.driver.api.core.cql.*; * @author Greg Turnquist * @since 4.0.0 */ -public class DefaultCassandraObservationContention implements CqlSessionObservationConvention { +public class DefaultCassandraObservationConvention implements CqlSessionObservationConvention { @Override public KeyValues getLowCardinalityKeyValues(CqlSessionContext context) { @@ -40,8 +40,8 @@ public class DefaultCassandraObservationContention implements CqlSessionObservat KeyValues keyValues = KeyValues.of( LowCardinalityKeyNames.SESSION_NAME .withValue(Optional.ofNullable(context.getDelegateSession().getName()).orElse("unknown")), - LowCardinalityKeyNames.KEYSPACE_NAME.withValue( - Optional.ofNullable(context.getStatement().getKeyspace()).map(CqlIdentifier::asInternal).orElse("unknown")), + LowCardinalityKeyNames.KEYSPACE_NAME + .withValue(context.getDelegateSession().getKeyspace().map(CqlIdentifier::asInternal).orElse("unknown")), LowCardinalityKeyNames.METHOD_NAME.withValue(context.getMethodName())); if (context.getStatement().getNode() != null) { diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/EnableCassandraObservability.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/EnableCassandraObservability.java new file mode 100644 index 000000000..4f9685cb5 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/observability/EnableCassandraObservability.java @@ -0,0 +1,34 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * 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 org.springframework.data.cassandra.observability; + +import java.lang.annotation.*; + +import org.springframework.context.annotation.Import; + +/** + * Annotation to enable Cassandra observability. + * + * @author Greg Turnquist + * @since 4.0.0 + */ +@Inherited +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(CassandraObservationConfiguration.class) +public @interface EnableCassandraObservability { +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingBeanPostProcessorTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingBeanPostProcessorTests.java index c1e8c5f5a..dedbc853e 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingBeanPostProcessorTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingBeanPostProcessorTests.java @@ -18,7 +18,13 @@ package org.springframework.data.cassandra.observability; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import io.micrometer.observation.ObservationHandler; import io.micrometer.observation.ObservationRegistry; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.test.simple.SimpleTracer; + +import java.lang.reflect.Method; +import java.util.Collection; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,6 +35,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.util.ReflectionUtils; import com.datastax.oss.driver.api.core.CqlSession; @@ -44,6 +51,8 @@ import com.datastax.oss.driver.api.core.CqlSession; public class CqlSessionTracingBeanPostProcessorTests { @Autowired CqlSession session; + @Autowired ObservationRegistry registry; + @Autowired CqlSessionTracingObservationHandler handler; @Test void injectedCqlSessionShouldBeWrapped() throws Exception { @@ -58,7 +67,22 @@ public class CqlSessionTracingBeanPostProcessorTests { assertThat(advised.getTargetSource().getTarget()).isEqualTo(TestConfig.originalSession); } + @Test + void injectedObservationHandlerIsRegisteredWithRegistry() { + + ObservationRegistry.ObservationConfig config = registry.observationConfig(); + + Method getObservationHandlers = ReflectionUtils.findMethod(ObservationRegistry.ObservationConfig.class, + "getObservationHandlers"); + ReflectionUtils.makeAccessible(getObservationHandlers); + Collection> handlers = (Collection>) ReflectionUtils + .invokeMethod(getObservationHandlers, config); + + assertThat(handlers).contains(handler); + } + @Configuration + @EnableCassandraObservability static class TestConfig { static CqlSession originalSession = mock(CqlSession.class); @@ -74,14 +98,8 @@ public class CqlSessionTracingBeanPostProcessorTests { } @Bean - CqlSessionObservationConvention observationConvention() { - return new DefaultCassandraObservationContention(); - } - - @Bean - CqlSessionTracingBeanPostProcessor traceCqlSessionBeanPostProcessor(ObservationRegistry observationRegistry, - CqlSessionObservationConvention tagsProvider) { - return new CqlSessionTracingBeanPostProcessor(observationRegistry, tagsProvider); + Tracer tracer() { + return new SimpleTracer(); } } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingTests.java index c18b7981c..e28ae6dca 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/CqlSessionTracingTests.java @@ -92,7 +92,7 @@ public class CqlSessionTracingTests extends IntegrationTestsSupport { ObservationRegistry observationRegistry = ObservationRegistry.create(); observationRegistry.observationConfig().observationHandler(new DefaultMeterObservationHandler(meterRegistry)); - CqlSessionObservationConvention observationContention = new DefaultCassandraObservationContention(); + CqlSessionObservationConvention observationContention = new DefaultCassandraObservationConvention(); SimpleTracer tracer = new SimpleTracer(); observationRegistry.observationConfig().observationHandler(new CqlSessionTracingObservationHandler(tracer)); @@ -109,7 +109,7 @@ public class CqlSessionTracingTests extends IntegrationTestsSupport { ObservationRegistry observationRegistry = ObservationRegistry.create(); observationRegistry.observationConfig().observationHandler(new DefaultMeterObservationHandler(meterRegistry)); - CqlSessionObservationConvention observationContention = new DefaultCassandraObservationContention(); + CqlSessionObservationConvention observationContention = new DefaultCassandraObservationConvention(); SimpleTracer tracer = new SimpleTracer(); observationRegistry.observationConfig().observationHandler(new CqlSessionTracingObservationHandler(tracer)); @@ -125,7 +125,7 @@ public class CqlSessionTracingTests extends IntegrationTestsSupport { MeterRegistryAssert.then(meterRegistry).hasTimerWithNameAndTags(CASSANDRA_QUERY_OBSERVATION.getName(), KeyValues.of( // LowCardinalityKeyNames.SESSION_NAME.withValue("s5"), // - LowCardinalityKeyNames.KEYSPACE_NAME.withValue("unknown"), // + LowCardinalityKeyNames.KEYSPACE_NAME.withValue("system"), // KeyValue.of("error", "none") // )); @@ -140,7 +140,7 @@ public class CqlSessionTracingTests extends IntegrationTestsSupport { .hasRemoteServiceNameEqualTo("cassandra-s5") // .hasNameEqualTo(CASSANDRA_QUERY_OBSERVATION.getContextualName()) // .hasTag(LowCardinalityKeyNames.SESSION_NAME, "s5") // - .hasTag(LowCardinalityKeyNames.KEYSPACE_NAME, "unknown") // + .hasTag(LowCardinalityKeyNames.KEYSPACE_NAME, "system") // .hasTag(HighCardinalityKeyNames.CQL_TAG, CREATE_KEYSPACE) // .hasIpThatIsBlank() // .hasPortEqualTo(0) // diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/ZipkinIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/ZipkinIntegrationTests.java index 841b5619f..9018143b3 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/ZipkinIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/observability/ZipkinIntegrationTests.java @@ -27,7 +27,6 @@ import io.micrometer.tracing.test.reporter.BuildingBlocks; import java.util.Deque; import java.util.function.BiConsumer; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; @@ -46,7 +45,6 @@ import com.datastax.oss.driver.api.core.CqlSession; * @author Greg Turnquist * @since 4.0.0 */ -@Disabled("Run this manually to visually test spans in Zipkin") @ExtendWith({ SpringExtension.class, CassandraExtension.class }) @TestKeyspaceName public class ZipkinIntegrationTests extends SampleTestRunner { @@ -91,6 +89,9 @@ public class ZipkinIntegrationTests extends SampleTestRunner { return (tracer, meterRegistry) -> { + OBSERVATION_REGISTRY.observationConfig() + .observationHandler(new CqlSessionTracingObservationHandler(tracer.getTracer())); + session.execute("DROP KEYSPACE IF EXISTS ConfigTest"); session.execute("CREATE KEYSPACE ConfigTest " + "WITH " + "REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 };"); @@ -117,13 +118,13 @@ public class ZipkinIntegrationTests extends SampleTestRunner { @Bean CqlSessionObservationConvention observationContention() { - return new DefaultCassandraObservationContention(); + return new DefaultCassandraObservationConvention(); } @Bean CqlSessionTracingBeanPostProcessor traceCqlSessionBeanPostProcessor(ObservationRegistry observationRegistry, - CqlSessionObservationConvention tagsProvider) { - return new CqlSessionTracingBeanPostProcessor(observationRegistry, tagsProvider); + CqlSessionObservationConvention observationConvention) { + return new CqlSessionTracingBeanPostProcessor(observationRegistry, observationConvention); } } } diff --git a/src/main/asciidoc/reference/observability.adoc b/src/main/asciidoc/reference/observability.adoc index f281c4295..62c85fb47 100644 --- a/src/main/asciidoc/reference/observability.adoc +++ b/src/main/asciidoc/reference/observability.adoc @@ -8,3 +8,77 @@ include::{root-target}_conventions.adoc[] include::{root-target}_metrics.adoc[] include::{root-target}_spans.adoc[] + +[[observability.registration]] +== Observability Registration + +Spring Data Cassandra is not yet supported in Spring Boot to automatically enable observability. +Don't worry, we've got you covered! +Simply add the `@EnableCassandraObservability` annotation to your Spring Boot application, and you'll be all set. + +.Activating observability for your Spring Boot application +==== +[source,java] +---- +@SpringBootApplication +@EnableCassandraObservability // <1> +public class SpringDataCassandraObservabilityApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringDataCassandraObservabilityApplication.class, args); + } +} +---- +<1> This annotation will activate the bits needed start wrapping CQL calls and register them with your tracer of choice. +==== + +By the way, Spring Boot DOES have autoconfigured hooks into various parts of the system. +For example, there is an observation filter that Spring Boot will apply to Spring MVC ensuring all your calls are wrapped properly. +And if anywhere in the midst of that web call, you invoke Spring Data Cassandra (either through `CqlTemplate` or a custom repository), it will get captured properly. + +Something that is NOT covered are situations where your code runs independently. +For example, if you have some block that is run during startup inside a `CommandLineRunner`, there is no way for Spring Boot to know that this should observed. +Check out the code block below: + +.Loading data for a sample application +==== +[source,java] +---- +@Bean +CommandLineRunner initData(EmployeeRepository repository) { + return args -> { + repository.save(new Employee("1", "Frodo", "ring bearer")); + repository.save(new Employee("2", "Bilbo", "burglar")); + }; +} +---- +This tactic is used all the time in demos. These calls to a Spring Data Cassandra repository will NOT be observed. +==== + +If you DO want to observe such a code block, you must wrap it yourself, as shown below: + +.Observing the loading of data in a sample application +==== +[source,java] +---- +@Bean +CommandLineRunner initData(EmployeeRepository repository, ObservationRegistry registry) { // <1> + return args -> { + Observation.createNotStarted("init-database", registry).observe(() -> { // <2> + repository.save(new Employee("1", "Frodo", "ring bearer")); + repository.save(new Employee("2", "Bilbo", "burglar")); + }); + }; +} +---- +<1> Your `CommandLineRunner` requires access to the app context's `ObservationRegistry` +<2> You need to create your own `Observation` using the `createNotStarted()` method. Give it any contextual name you like, but be sure to include the `registry`. +==== + +The `observe()` method takes a Java 8 lambda function which is invoked inside a common Micrometer pattern of: + +* Starting the observation. +* Invoking your callback. +* Properly ending the observation by either reporting an error if it fails or stopping the observation if it succeeds. + +This will allow you to observe chunks of code that may fall outside of currently autoconfigured operations.