Skip to content

Commit a86fc2c

Browse files
authored
Add AggregatedMetrics to support multiple Metrics implementations (#2917)
* feature: add AggregatedMetrics to support multiple Metrics implementations Signed-off-by: David Sondermann <[email protected]>
1 parent 9a19dcb commit a86fc2c

File tree

3 files changed

+317
-7
lines changed

3 files changed

+317
-7
lines changed

docs/content/en/docs/documentation/observability.md

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Operator operator = new Operator(client, o -> o.withMetrics(metrics));
5151
### Micrometer implementation
5252

5353
The micrometer implementation is typically created using one of the provided factory methods which, depending on which
54-
is used, will return either a ready to use instance or a builder allowing users to customized how the implementation
54+
is used, will return either a ready to use instance or a builder allowing users to customize how the implementation
5555
behaves, in particular when it comes to the granularity of collected metrics. It is, for example, possible to collect
5656
metrics on a per-resource basis via tags that are associated with meters. This is the default, historical behavior but
5757
this will change in a future version of JOSDK because this dramatically increases the cardinality of metrics, which
@@ -62,14 +62,13 @@ instance via:
6262

6363
```java
6464
MeterRegistry registry; // initialize your registry implementation
65-
Metrics metrics = new MicrometerMetrics(registry);
65+
Metrics metrics = MicrometerMetrics.newMicrometerMetricsBuilder(registry).build();
6666
```
6767

68-
Note, however, that this constructor is deprecated and we encourage you to use the factory methods instead, which either
69-
return a fully pre-configured instance or a builder object that will allow you to configure more easily how the instance
70-
will behave. You can, for example, configure whether or not the implementation should collect metrics on a per-resource
71-
basis, whether or not associated meters should be removed when a resource is deleted and how the clean-up is performed.
72-
See the relevant classes documentation for more details.
68+
The class provides factory methods which either return a fully pre-configured instance or a builder object that will
69+
allow you to configure more easily how the instance will behave. You can, for example, configure whether the
70+
implementation should collect metrics on a per-resource basis, whether associated meters should be removed when a
71+
resource is deleted and how the clean-up is performed. See the relevant classes documentation for more details.
7372

7473
For example, the following will create a `MicrometerMetrics` instance configured to collect metrics on a per-resource
7574
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
109108
omitted if the associated value is empty. Of note, when in the context of controllers' execution metrics, these tag
110109
names are prefixed with `resource.`. This prefix might be removed in a future version for greater consistency.
111110

111+
### Aggregated Metrics
112112

113+
The `AggregatedMetrics` class provides a way to combine multiple metrics providers into a single metrics instance using
114+
the composite pattern. This is particularly useful when you want to simultaneously collect metrics data from different
115+
monitoring systems or providers.
116+
117+
You can create an `AggregatedMetrics` instance by providing a list of existing metrics implementations:
118+
119+
```java
120+
// create individual metrics instances
121+
Metrics micrometerMetrics = MicrometerMetrics.withoutPerResourceMetrics(registry);
122+
Metrics customMetrics = new MyCustomMetrics();
123+
Metrics loggingMetrics = new LoggingMetrics();
124+
125+
// combine them into a single aggregated instance
126+
Metrics aggregatedMetrics = new AggregatedMetrics(List.of(
127+
micrometerMetrics,
128+
customMetrics,
129+
loggingMetrics
130+
));
131+
132+
// use the aggregated metrics with your operator
133+
Operator operator = new Operator(client, o -> o.withMetrics(aggregatedMetrics));
134+
```
135+
136+
This approach allows you to easily combine different metrics collection strategies, such as sending metrics to both
137+
Prometheus (via Micrometer) and a custom logging system simultaneously.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package io.javaoperatorsdk.operator.api.monitoring;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
import java.util.Objects;
6+
7+
import io.fabric8.kubernetes.api.model.HasMetadata;
8+
import io.javaoperatorsdk.operator.api.reconciler.RetryInfo;
9+
import io.javaoperatorsdk.operator.processing.Controller;
10+
import io.javaoperatorsdk.operator.processing.event.Event;
11+
import io.javaoperatorsdk.operator.processing.event.ResourceID;
12+
13+
/**
14+
* An aggregated implementation of the {@link Metrics} interface that delegates method calls to a
15+
* collection of {@link Metrics} instances using the composite pattern.
16+
*
17+
* <p>This class allows multiple metrics providers to be combined into a single metrics instance,
18+
* enabling simultaneous collection of metrics data by different monitoring systems or providers.
19+
* All method calls are delegated to each metrics instance in the list in the order they were
20+
* provided to the constructor.
21+
*
22+
* <p><strong>Important:</strong> The {@link #timeControllerExecution(ControllerExecution)} method
23+
* is handled specially - it is only invoked on the first metrics instance in the list, since it's
24+
* not an idempotent operation and can only be executed once. The controller execution cannot be
25+
* repeated multiple times as it would produce side effects and potentially inconsistent results.
26+
*
27+
* <p>All other methods are called on every metrics instance in the list, preserving the order of
28+
* execution as specified in the constructor.
29+
*
30+
* @see Metrics
31+
*/
32+
public final class AggregatedMetrics implements Metrics {
33+
34+
private final List<Metrics> metricsList;
35+
36+
/**
37+
* Creates a new AggregatedMetrics instance that will delegate method calls to the provided list
38+
* of metrics instances.
39+
*
40+
* @param metricsList the list of metrics instances to delegate to; must not be null and must
41+
* contain at least one metrics instance
42+
* @throws NullPointerException if metricsList is null
43+
* @throws IllegalArgumentException if metricsList is empty
44+
*/
45+
public AggregatedMetrics(List<Metrics> metricsList) {
46+
Objects.requireNonNull(metricsList, "metricsList must not be null");
47+
if (metricsList.isEmpty()) {
48+
throw new IllegalArgumentException("metricsList must contain at least one Metrics instance");
49+
}
50+
this.metricsList = List.copyOf(metricsList);
51+
}
52+
53+
@Override
54+
public void controllerRegistered(Controller<? extends HasMetadata> controller) {
55+
metricsList.forEach(metrics -> metrics.controllerRegistered(controller));
56+
}
57+
58+
@Override
59+
public void receivedEvent(Event event, Map<String, Object> metadata) {
60+
metricsList.forEach(metrics -> metrics.receivedEvent(event, metadata));
61+
}
62+
63+
@Override
64+
public void reconcileCustomResource(
65+
HasMetadata resource, RetryInfo retryInfo, Map<String, Object> metadata) {
66+
metricsList.forEach(metrics -> metrics.reconcileCustomResource(resource, retryInfo, metadata));
67+
}
68+
69+
@Override
70+
public void failedReconciliation(
71+
HasMetadata resource, Exception exception, Map<String, Object> metadata) {
72+
metricsList.forEach(metrics -> metrics.failedReconciliation(resource, exception, metadata));
73+
}
74+
75+
@Override
76+
public void reconciliationExecutionStarted(HasMetadata resource, Map<String, Object> metadata) {
77+
metricsList.forEach(metrics -> metrics.reconciliationExecutionStarted(resource, metadata));
78+
}
79+
80+
@Override
81+
public void reconciliationExecutionFinished(HasMetadata resource, Map<String, Object> metadata) {
82+
metricsList.forEach(metrics -> metrics.reconciliationExecutionFinished(resource, metadata));
83+
}
84+
85+
@Override
86+
public void cleanupDoneFor(ResourceID resourceID, Map<String, Object> metadata) {
87+
metricsList.forEach(metrics -> metrics.cleanupDoneFor(resourceID, metadata));
88+
}
89+
90+
@Override
91+
public void finishedReconciliation(HasMetadata resource, Map<String, Object> metadata) {
92+
metricsList.forEach(metrics -> metrics.finishedReconciliation(resource, metadata));
93+
}
94+
95+
@Override
96+
public <T> T timeControllerExecution(ControllerExecution<T> execution) throws Exception {
97+
return metricsList.get(0).timeControllerExecution(execution);
98+
}
99+
100+
@Override
101+
public <T extends Map<?, ?>> T monitorSizeOf(T map, String name) {
102+
metricsList.forEach(metrics -> metrics.monitorSizeOf(map, name));
103+
return map;
104+
}
105+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package io.javaoperatorsdk.operator.api.monitoring;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
import org.junit.jupiter.api.Test;
7+
8+
import io.fabric8.kubernetes.api.model.HasMetadata;
9+
import io.javaoperatorsdk.operator.api.reconciler.RetryInfo;
10+
import io.javaoperatorsdk.operator.processing.Controller;
11+
import io.javaoperatorsdk.operator.processing.event.Event;
12+
import io.javaoperatorsdk.operator.processing.event.ResourceID;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
16+
import static org.mockito.ArgumentMatchers.any;
17+
import static org.mockito.Mockito.*;
18+
19+
class AggregatedMetricsTest {
20+
21+
private final Metrics metrics1 = mock();
22+
private final Metrics metrics2 = mock();
23+
private final Metrics metrics3 = mock();
24+
private final Controller<HasMetadata> controller = mock();
25+
private final Event event = mock();
26+
private final HasMetadata resource = mock();
27+
private final RetryInfo retryInfo = mock();
28+
private final ResourceID resourceID = mock();
29+
private final Metrics.ControllerExecution<String> controllerExecution = mock();
30+
31+
private final Map<String, Object> metadata = Map.of("kind", "TestResource");
32+
private final AggregatedMetrics aggregatedMetrics =
33+
new AggregatedMetrics(List.of(metrics1, metrics2, metrics3));
34+
35+
@Test
36+
void constructor_shouldThrowNullPointerExceptionWhenMetricsListIsNull() {
37+
assertThatThrownBy(() -> new AggregatedMetrics(null))
38+
.isInstanceOf(NullPointerException.class)
39+
.hasMessage("metricsList must not be null");
40+
}
41+
42+
@Test
43+
void constructor_shouldThrowIllegalArgumentExceptionWhenMetricsListIsEmpty() {
44+
assertThatThrownBy(() -> new AggregatedMetrics(List.of()))
45+
.isInstanceOf(IllegalArgumentException.class)
46+
.hasMessage("metricsList must contain at least one Metrics instance");
47+
}
48+
49+
@Test
50+
void controllerRegistered_shouldDelegateToAllMetricsInOrder() {
51+
aggregatedMetrics.controllerRegistered(controller);
52+
53+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
54+
inOrder.verify(metrics1).controllerRegistered(controller);
55+
inOrder.verify(metrics2).controllerRegistered(controller);
56+
inOrder.verify(metrics3).controllerRegistered(controller);
57+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
58+
}
59+
60+
@Test
61+
void receivedEvent_shouldDelegateToAllMetricsInOrder() {
62+
aggregatedMetrics.receivedEvent(event, metadata);
63+
64+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
65+
inOrder.verify(metrics1).receivedEvent(event, metadata);
66+
inOrder.verify(metrics2).receivedEvent(event, metadata);
67+
inOrder.verify(metrics3).receivedEvent(event, metadata);
68+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
69+
}
70+
71+
@Test
72+
void reconcileCustomResource_shouldDelegateToAllMetricsInOrder() {
73+
aggregatedMetrics.reconcileCustomResource(resource, retryInfo, metadata);
74+
75+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
76+
inOrder.verify(metrics1).reconcileCustomResource(resource, retryInfo, metadata);
77+
inOrder.verify(metrics2).reconcileCustomResource(resource, retryInfo, metadata);
78+
inOrder.verify(metrics3).reconcileCustomResource(resource, retryInfo, metadata);
79+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
80+
}
81+
82+
@Test
83+
void failedReconciliation_shouldDelegateToAllMetricsInOrder() {
84+
final var exception = new RuntimeException("Test exception");
85+
86+
aggregatedMetrics.failedReconciliation(resource, exception, metadata);
87+
88+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
89+
inOrder.verify(metrics1).failedReconciliation(resource, exception, metadata);
90+
inOrder.verify(metrics2).failedReconciliation(resource, exception, metadata);
91+
inOrder.verify(metrics3).failedReconciliation(resource, exception, metadata);
92+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
93+
}
94+
95+
@Test
96+
void reconciliationExecutionStarted_shouldDelegateToAllMetricsInOrder() {
97+
aggregatedMetrics.reconciliationExecutionStarted(resource, metadata);
98+
99+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
100+
inOrder.verify(metrics1).reconciliationExecutionStarted(resource, metadata);
101+
inOrder.verify(metrics2).reconciliationExecutionStarted(resource, metadata);
102+
inOrder.verify(metrics3).reconciliationExecutionStarted(resource, metadata);
103+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
104+
}
105+
106+
@Test
107+
void reconciliationExecutionFinished_shouldDelegateToAllMetricsInOrder() {
108+
aggregatedMetrics.reconciliationExecutionFinished(resource, metadata);
109+
110+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
111+
inOrder.verify(metrics1).reconciliationExecutionFinished(resource, metadata);
112+
inOrder.verify(metrics2).reconciliationExecutionFinished(resource, metadata);
113+
inOrder.verify(metrics3).reconciliationExecutionFinished(resource, metadata);
114+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
115+
}
116+
117+
@Test
118+
void cleanupDoneFor_shouldDelegateToAllMetricsInOrder() {
119+
aggregatedMetrics.cleanupDoneFor(resourceID, metadata);
120+
121+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
122+
inOrder.verify(metrics1).cleanupDoneFor(resourceID, metadata);
123+
inOrder.verify(metrics2).cleanupDoneFor(resourceID, metadata);
124+
inOrder.verify(metrics3).cleanupDoneFor(resourceID, metadata);
125+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
126+
}
127+
128+
@Test
129+
void finishedReconciliation_shouldDelegateToAllMetricsInOrder() {
130+
aggregatedMetrics.finishedReconciliation(resource, metadata);
131+
132+
final var inOrder = inOrder(metrics1, metrics2, metrics3);
133+
inOrder.verify(metrics1).finishedReconciliation(resource, metadata);
134+
inOrder.verify(metrics2).finishedReconciliation(resource, metadata);
135+
inOrder.verify(metrics3).finishedReconciliation(resource, metadata);
136+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
137+
}
138+
139+
@Test
140+
void timeControllerExecution_shouldOnlyDelegateToFirstMetrics() throws Exception {
141+
final var expectedResult = "execution result";
142+
when(metrics1.timeControllerExecution(controllerExecution)).thenReturn(expectedResult);
143+
144+
final var result = aggregatedMetrics.timeControllerExecution(controllerExecution);
145+
146+
assertThat(result).isEqualTo(expectedResult);
147+
verify(metrics1).timeControllerExecution(controllerExecution);
148+
verify(metrics2, never()).timeControllerExecution(any());
149+
verify(metrics3, never()).timeControllerExecution(any());
150+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
151+
}
152+
153+
@Test
154+
void timeControllerExecution_shouldPropagateException() throws Exception {
155+
final var expectedException = new RuntimeException("Controller execution failed");
156+
when(metrics1.timeControllerExecution(controllerExecution)).thenThrow(expectedException);
157+
158+
assertThatThrownBy(() -> aggregatedMetrics.timeControllerExecution(controllerExecution))
159+
.isSameAs(expectedException);
160+
161+
verify(metrics1).timeControllerExecution(controllerExecution);
162+
verify(metrics2, never()).timeControllerExecution(any());
163+
verify(metrics3, never()).timeControllerExecution(any());
164+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
165+
}
166+
167+
@Test
168+
void monitorSizeOf_shouldDelegateToAllMetricsInOrderAndReturnOriginalMap() {
169+
final var testMap = Map.of("key1", "value1");
170+
final var mapName = "testMap";
171+
172+
final var result = aggregatedMetrics.monitorSizeOf(testMap, mapName);
173+
174+
assertThat(result).isSameAs(testMap);
175+
verify(metrics1).monitorSizeOf(testMap, mapName);
176+
verify(metrics2).monitorSizeOf(testMap, mapName);
177+
verify(metrics3).monitorSizeOf(testMap, mapName);
178+
verifyNoMoreInteractions(metrics1, metrics2, metrics3);
179+
}
180+
}

0 commit comments

Comments
 (0)