diff --git a/docs/content/en/docs/documentation/observability.md b/docs/content/en/docs/documentation/observability.md index 27a68086d5..312c31967e 100644 --- a/docs/content/en/docs/documentation/observability.md +++ b/docs/content/en/docs/documentation/observability.md @@ -51,7 +51,7 @@ Operator operator = new Operator(client, o -> o.withMetrics(metrics)); ### Micrometer implementation The micrometer implementation is typically created using one of the provided factory methods which, depending on which -is used, will return either a ready to use instance or a builder allowing users to customized how the implementation +is used, will return either a ready to use instance or a builder allowing users to customize how the implementation behaves, in particular when it comes to the granularity of collected metrics. It is, for example, possible to collect metrics on a per-resource basis via tags that are associated with meters. This is the default, historical behavior but this will change in a future version of JOSDK because this dramatically increases the cardinality of metrics, which @@ -62,14 +62,13 @@ instance via: ```java MeterRegistry registry; // initialize your registry implementation -Metrics metrics = new MicrometerMetrics(registry); +Metrics metrics = MicrometerMetrics.newMicrometerMetricsBuilder(registry).build(); ``` -Note, however, that this constructor is deprecated and we encourage you to use the factory methods instead, which either -return a fully pre-configured instance or a builder object that will allow you to configure more easily how the instance -will behave. You can, for example, configure whether or not the implementation should collect metrics on a per-resource -basis, whether or not associated meters should be removed when a resource is deleted and how the clean-up is performed. -See the relevant classes documentation for more details. +The class provides factory methods which either return a fully pre-configured instance or a builder object that will +allow you to configure more easily how the instance will behave. You can, for example, configure whether the +implementation should collect metrics on a per-resource basis, whether associated meters should be removed when a +resource is deleted and how the clean-up is performed. See the relevant classes documentation for more details. For example, the following will create a `MicrometerMetrics` instance configured to collect metrics on a per-resource basis, deleting the associated meters after 5 seconds when a resource is deleted, using up to 2 threads to do so. @@ -109,4 +108,30 @@ brackets (`[]`) won't be present when per-resource collection is disabled and ta omitted if the associated value is empty. Of note, when in the context of controllers' execution metrics, these tag names are prefixed with `resource.`. This prefix might be removed in a future version for greater consistency. +### Aggregated Metrics +The `AggregatedMetrics` class provides a way to combine multiple metrics providers into a single metrics instance using +the composite pattern. This is particularly useful when you want to simultaneously collect metrics data from different +monitoring systems or providers. + +You can create an `AggregatedMetrics` instance by providing a list of existing metrics implementations: + +```java +// create individual metrics instances +Metrics micrometerMetrics = MicrometerMetrics.withoutPerResourceMetrics(registry); +Metrics customMetrics = new MyCustomMetrics(); +Metrics loggingMetrics = new LoggingMetrics(); + +// combine them into a single aggregated instance +Metrics aggregatedMetrics = new AggregatedMetrics(List.of( + micrometerMetrics, + customMetrics, + loggingMetrics +)); + +// use the aggregated metrics with your operator +Operator operator = new Operator(client, o -> o.withMetrics(aggregatedMetrics)); +``` + +This approach allows you to easily combine different metrics collection strategies, such as sending metrics to both +Prometheus (via Micrometer) and a custom logging system simultaneously. diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java new file mode 100644 index 0000000000..45b08f4a3b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java @@ -0,0 +1,105 @@ +package io.javaoperatorsdk.operator.api.monitoring; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * An aggregated implementation of the {@link Metrics} interface that delegates method calls to a + * collection of {@link Metrics} instances using the composite pattern. + * + *

This class allows multiple metrics providers to be combined into a single metrics instance, + * enabling simultaneous collection of metrics data by different monitoring systems or providers. + * All method calls are delegated to each metrics instance in the list in the order they were + * provided to the constructor. + * + *

Important: The {@link #timeControllerExecution(ControllerExecution)} method + * is handled specially - it is only invoked on the first metrics instance in the list, since it's + * not an idempotent operation and can only be executed once. The controller execution cannot be + * repeated multiple times as it would produce side effects and potentially inconsistent results. + * + *

All other methods are called on every metrics instance in the list, preserving the order of + * execution as specified in the constructor. + * + * @see Metrics + */ +public final class AggregatedMetrics implements Metrics { + + private final List metricsList; + + /** + * Creates a new AggregatedMetrics instance that will delegate method calls to the provided list + * of metrics instances. + * + * @param metricsList the list of metrics instances to delegate to; must not be null and must + * contain at least one metrics instance + * @throws NullPointerException if metricsList is null + * @throws IllegalArgumentException if metricsList is empty + */ + public AggregatedMetrics(List metricsList) { + Objects.requireNonNull(metricsList, "metricsList must not be null"); + if (metricsList.isEmpty()) { + throw new IllegalArgumentException("metricsList must contain at least one Metrics instance"); + } + this.metricsList = List.copyOf(metricsList); + } + + @Override + public void controllerRegistered(Controller controller) { + metricsList.forEach(metrics -> metrics.controllerRegistered(controller)); + } + + @Override + public void receivedEvent(Event event, Map metadata) { + metricsList.forEach(metrics -> metrics.receivedEvent(event, metadata)); + } + + @Override + public void reconcileCustomResource( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { + metricsList.forEach(metrics -> metrics.reconcileCustomResource(resource, retryInfo, metadata)); + } + + @Override + public void failedReconciliation( + HasMetadata resource, Exception exception, Map metadata) { + metricsList.forEach(metrics -> metrics.failedReconciliation(resource, exception, metadata)); + } + + @Override + public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.reconciliationExecutionStarted(resource, metadata)); + } + + @Override + public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.reconciliationExecutionFinished(resource, metadata)); + } + + @Override + public void cleanupDoneFor(ResourceID resourceID, Map metadata) { + metricsList.forEach(metrics -> metrics.cleanupDoneFor(resourceID, metadata)); + } + + @Override + public void finishedReconciliation(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.finishedReconciliation(resource, metadata)); + } + + @Override + public T timeControllerExecution(ControllerExecution execution) throws Exception { + return metricsList.get(0).timeControllerExecution(execution); + } + + @Override + public > T monitorSizeOf(T map, String name) { + metricsList.forEach(metrics -> metrics.monitorSizeOf(map, name)); + return map; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java new file mode 100644 index 0000000000..38781b94c4 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java @@ -0,0 +1,180 @@ +package io.javaoperatorsdk.operator.api.monitoring; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class AggregatedMetricsTest { + + private final Metrics metrics1 = mock(); + private final Metrics metrics2 = mock(); + private final Metrics metrics3 = mock(); + private final Controller controller = mock(); + private final Event event = mock(); + private final HasMetadata resource = mock(); + private final RetryInfo retryInfo = mock(); + private final ResourceID resourceID = mock(); + private final Metrics.ControllerExecution controllerExecution = mock(); + + private final Map metadata = Map.of("kind", "TestResource"); + private final AggregatedMetrics aggregatedMetrics = + new AggregatedMetrics(List.of(metrics1, metrics2, metrics3)); + + @Test + void constructor_shouldThrowNullPointerExceptionWhenMetricsListIsNull() { + assertThatThrownBy(() -> new AggregatedMetrics(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("metricsList must not be null"); + } + + @Test + void constructor_shouldThrowIllegalArgumentExceptionWhenMetricsListIsEmpty() { + assertThatThrownBy(() -> new AggregatedMetrics(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("metricsList must contain at least one Metrics instance"); + } + + @Test + void controllerRegistered_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.controllerRegistered(controller); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).controllerRegistered(controller); + inOrder.verify(metrics2).controllerRegistered(controller); + inOrder.verify(metrics3).controllerRegistered(controller); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void receivedEvent_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.receivedEvent(event, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).receivedEvent(event, metadata); + inOrder.verify(metrics2).receivedEvent(event, metadata); + inOrder.verify(metrics3).receivedEvent(event, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void reconcileCustomResource_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconcileCustomResource(resource, retryInfo, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).reconcileCustomResource(resource, retryInfo, metadata); + inOrder.verify(metrics2).reconcileCustomResource(resource, retryInfo, metadata); + inOrder.verify(metrics3).reconcileCustomResource(resource, retryInfo, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void failedReconciliation_shouldDelegateToAllMetricsInOrder() { + final var exception = new RuntimeException("Test exception"); + + aggregatedMetrics.failedReconciliation(resource, exception, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).failedReconciliation(resource, exception, metadata); + inOrder.verify(metrics2).failedReconciliation(resource, exception, metadata); + inOrder.verify(metrics3).failedReconciliation(resource, exception, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void reconciliationExecutionStarted_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationExecutionStarted(resource, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).reconciliationExecutionStarted(resource, metadata); + inOrder.verify(metrics2).reconciliationExecutionStarted(resource, metadata); + inOrder.verify(metrics3).reconciliationExecutionStarted(resource, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void reconciliationExecutionFinished_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationExecutionFinished(resource, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).reconciliationExecutionFinished(resource, metadata); + inOrder.verify(metrics2).reconciliationExecutionFinished(resource, metadata); + inOrder.verify(metrics3).reconciliationExecutionFinished(resource, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void cleanupDoneFor_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.cleanupDoneFor(resourceID, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).cleanupDoneFor(resourceID, metadata); + inOrder.verify(metrics2).cleanupDoneFor(resourceID, metadata); + inOrder.verify(metrics3).cleanupDoneFor(resourceID, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void finishedReconciliation_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.finishedReconciliation(resource, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).finishedReconciliation(resource, metadata); + inOrder.verify(metrics2).finishedReconciliation(resource, metadata); + inOrder.verify(metrics3).finishedReconciliation(resource, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void timeControllerExecution_shouldOnlyDelegateToFirstMetrics() throws Exception { + final var expectedResult = "execution result"; + when(metrics1.timeControllerExecution(controllerExecution)).thenReturn(expectedResult); + + final var result = aggregatedMetrics.timeControllerExecution(controllerExecution); + + assertThat(result).isEqualTo(expectedResult); + verify(metrics1).timeControllerExecution(controllerExecution); + verify(metrics2, never()).timeControllerExecution(any()); + verify(metrics3, never()).timeControllerExecution(any()); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void timeControllerExecution_shouldPropagateException() throws Exception { + final var expectedException = new RuntimeException("Controller execution failed"); + when(metrics1.timeControllerExecution(controllerExecution)).thenThrow(expectedException); + + assertThatThrownBy(() -> aggregatedMetrics.timeControllerExecution(controllerExecution)) + .isSameAs(expectedException); + + verify(metrics1).timeControllerExecution(controllerExecution); + verify(metrics2, never()).timeControllerExecution(any()); + verify(metrics3, never()).timeControllerExecution(any()); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void monitorSizeOf_shouldDelegateToAllMetricsInOrderAndReturnOriginalMap() { + final var testMap = Map.of("key1", "value1"); + final var mapName = "testMap"; + + final var result = aggregatedMetrics.monitorSizeOf(testMap, mapName); + + assertThat(result).isSameAs(testMap); + verify(metrics1).monitorSizeOf(testMap, mapName); + verify(metrics2).monitorSizeOf(testMap, mapName); + verify(metrics3).monitorSizeOf(testMap, mapName); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } +}