Skip to content

Commit 3beeee1

Browse files
Validate low cardinality keys (#6713)
Observations with the same name should have the same set of low cardinality keys
1 parent 366a76a commit 3beeee1

File tree

4 files changed

+200
-22
lines changed

4 files changed

+200
-22
lines changed

micrometer-observation-test/src/main/java/io/micrometer/observation/tck/InvalidObservationException.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import io.micrometer.observation.Observation.Context;
2020

2121
import java.util.Arrays;
22+
import java.util.Collections;
2223
import java.util.List;
2324
import java.util.stream.Collectors;
2425

@@ -37,6 +38,10 @@ public class InvalidObservationException extends RuntimeException {
3738

3839
private final List<HistoryElement> history;
3940

41+
InvalidObservationException(String message, Context context) {
42+
this(message, context, Collections.emptyList());
43+
}
44+
4045
InvalidObservationException(String message, Context context, List<HistoryElement> history) {
4146
super(message);
4247
this.context = context;
@@ -53,8 +58,13 @@ public List<HistoryElement> getHistory() {
5358

5459
@Override
5560
public String toString() {
56-
return super.toString() + "\n"
57-
+ history.stream().map(HistoryElement::toString).collect(Collectors.joining("\n"));
61+
if (history.isEmpty()) {
62+
return super.toString();
63+
}
64+
else {
65+
return super.toString() + "\n"
66+
+ history.stream().map(HistoryElement::toString).collect(Collectors.joining("\n"));
67+
}
5868
}
5969

6070
public static class HistoryElement {

micrometer-observation-test/src/main/java/io/micrometer/observation/tck/ObservationValidator.java

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package io.micrometer.observation.tck;
1717

18+
import io.micrometer.common.KeyValue;
19+
import io.micrometer.common.KeyValues;
1820
import io.micrometer.observation.NullObservation.NullContext;
1921
import io.micrometer.observation.Observation.Context;
2022
import io.micrometer.observation.Observation.Event;
@@ -23,11 +25,13 @@
2325
import io.micrometer.observation.tck.InvalidObservationException.HistoryElement;
2426
import org.jspecify.annotations.Nullable;
2527

26-
import java.util.ArrayList;
27-
import java.util.Collections;
28-
import java.util.List;
28+
import java.util.*;
2929
import java.util.function.Consumer;
3030
import java.util.function.Predicate;
31+
import java.util.stream.Collectors;
32+
33+
import static io.micrometer.observation.tck.TestObservationRegistry.Capability;
34+
import static io.micrometer.observation.tck.TestObservationRegistry.Capability.OBSERVATIONS_WITH_THE_SAME_NAME_SHOULD_HAVE_THE_SAME_SET_OF_LOW_CARDINALITY_KEYS;
3135

3236
/**
3337
* An {@link ObservationHandler} that validates the order of events of an Observation (for
@@ -43,17 +47,15 @@ class ObservationValidator implements ObservationHandler<Context> {
4347

4448
private final Predicate<Context> supportsContextPredicate;
4549

46-
ObservationValidator() {
47-
this(ObservationValidator::throwInvalidObservationException);
48-
}
50+
private final Map<String, Set<String>> lowCardinalityKeys;
4951

50-
ObservationValidator(Consumer<ValidationResult> consumer) {
51-
this(consumer, context -> !(context instanceof NullContext));
52-
}
52+
private final Set<Capability> capabilities;
5353

54-
ObservationValidator(Consumer<ValidationResult> consumer, Predicate<Context> supportsContextPredicate) {
55-
this.consumer = consumer;
56-
this.supportsContextPredicate = supportsContextPredicate;
54+
ObservationValidator(Set<Capability> capabilities) {
55+
this.consumer = ObservationValidator::throwInvalidObservationException;
56+
this.supportsContextPredicate = context -> !(context instanceof NullContext);
57+
this.lowCardinalityKeys = new HashMap<>();
58+
this.capabilities = capabilities;
5759
}
5860

5961
@Override
@@ -109,6 +111,9 @@ public void onStop(Context context) {
109111
if (status != null) {
110112
status.markStopped();
111113
}
114+
if (capabilities.contains(OBSERVATIONS_WITH_THE_SAME_NAME_SHOULD_HAVE_THE_SAME_SET_OF_LOW_CARDINALITY_KEYS)) {
115+
checkIfObservationsWithTheSameNameHaveTheSameSetOfLowCardinalityKeys(context);
116+
}
112117
}
113118

114119
@Override
@@ -141,8 +146,34 @@ private void addHistoryElement(Context context, EventName eventName) {
141146
return status;
142147
}
143148

149+
private void checkIfObservationsWithTheSameNameHaveTheSameSetOfLowCardinalityKeys(Context context) {
150+
if (lowCardinalityKeys.containsKey(context.getName())) {
151+
Set<String> existingKeys = lowCardinalityKeys.get(context.getName());
152+
Set<String> currentKeys = getLowCardinalityKeys(context);
153+
if (!existingKeys.equals(currentKeys)) {
154+
String message = "Metrics backends may require that all observations with the same name have the same"
155+
+ " set of low cardinality keys. There is already an existing observation named '"
156+
+ context.getName() + "' containing keys [" + String.join(", ", existingKeys)
157+
+ "]. The observation you are attempting to register" + " has keys ["
158+
+ String.join(", ", currentKeys) + "].";
159+
throw new InvalidObservationException(message, context);
160+
}
161+
}
162+
else {
163+
lowCardinalityKeys.put(Objects.requireNonNull(context.getName()), getLowCardinalityKeys(context));
164+
}
165+
}
166+
167+
private Set<String> getLowCardinalityKeys(Context context) {
168+
return getKeys(context.getLowCardinalityKeyValues());
169+
}
170+
171+
private Set<String> getKeys(KeyValues keyValues) {
172+
return keyValues.stream().map(KeyValue::getKey).collect(Collectors.toSet());
173+
}
174+
144175
private static void throwInvalidObservationException(ValidationResult validationResult) {
145-
History history = validationResult.getContext().getOrDefault(History.class, () -> new History());
176+
History history = validationResult.getContext().getOrDefault(History.class, History::new);
146177
throw new InvalidObservationException(validationResult.getMessage(), validationResult.getContext(),
147178
history.getHistoryElements());
148179
}

micrometer-observation-test/src/main/java/io/micrometer/observation/tck/TestObservationRegistry.java

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@
2121
import org.assertj.core.api.AssertProvider;
2222
import org.jspecify.annotations.Nullable;
2323

24-
import java.util.HashSet;
25-
import java.util.Objects;
26-
import java.util.Queue;
27-
import java.util.Set;
24+
import java.util.*;
2825
import java.util.concurrent.ConcurrentLinkedQueue;
2926

27+
import static io.micrometer.observation.tck.TestObservationRegistry.Capability.OBSERVATIONS_WITH_THE_SAME_NAME_SHOULD_HAVE_THE_SAME_SET_OF_LOW_CARDINALITY_KEYS;
28+
3029
/**
3130
* Implementation of {@link ObservationRegistry} used for testing.
3231
*
@@ -42,16 +41,24 @@ public final class TestObservationRegistry
4241

4342
private final StoringObservationHandler handler = new StoringObservationHandler();
4443

45-
private TestObservationRegistry() {
46-
observationConfig().observationHandler(this.handler).observationHandler(new ObservationValidator());
44+
private TestObservationRegistry(Set<Capability> capabilities) {
45+
observationConfig().observationHandler(this.handler).observationHandler(new ObservationValidator(capabilities));
4746
}
4847

4948
/**
5049
* Crates a new instance of mock observation registry.
5150
* @return mock instance of observation registry
5251
*/
5352
public static TestObservationRegistry create() {
54-
return new TestObservationRegistry();
53+
return builder().build();
54+
}
55+
56+
/**
57+
* @return builder to create {@link TestObservationRegistry}.
58+
* @since 1.16.0
59+
*/
60+
public static TestObservationRegistryBuilder builder() {
61+
return new TestObservationRegistryBuilder();
5562
}
5663

5764
@Override
@@ -130,6 +137,67 @@ public void onEvent(Observation.Event event, Observation.Context context) {
130137

131138
}
132139

140+
/**
141+
* Builder to create {@link TestObservationRegistry}.
142+
*
143+
* @since 1.16.0
144+
*/
145+
public static class TestObservationRegistryBuilder {
146+
147+
private final Set<Capability> capabilities = new HashSet<>(Arrays.asList(Capability.values()));
148+
149+
/**
150+
* Enables/disables validating that Observations with the same name should have
151+
* the same set of low cardinality keys.
152+
* <p>
153+
* Example 1:
154+
* <p>
155+
* <pre>
156+
* observation{name=test, lowCardinalityKeyValues=[color=red]}
157+
* observation{name=test, lowCardinalityKeyValues=[color=green]}
158+
* observation{name=test, lowCardinalityKeyValues=[color=blue]}
159+
* </pre>
160+
* <p>
161+
* Example 1 is valid since all the observations with the same name ("test") has
162+
* the same set of low cardinality keys ("color").
163+
* <p>
164+
* Example 2:
165+
* <p>
166+
* <pre>
167+
* observation{name=test, lowCardinalityKeyValues=[color=red]}
168+
* observation{name=test, lowCardinalityKeyValues=[]}
169+
* observation{name=test, lowCardinalityKeyValues=[status=ok]}
170+
* </pre>
171+
* <p>
172+
* Example 2 is invalid since the second observation is missing a key the first
173+
* one has ("color") and the third observation is not only missing a key the first
174+
* one has ("color") but it also has an extra key the first one does not have
175+
* ("status").
176+
*/
177+
public TestObservationRegistryBuilder validateObservationsWithTheSameNameHavingTheSameSetOfLowCardinalityKeys(
178+
boolean flag) {
179+
if (flag) {
180+
this.capabilities.add(OBSERVATIONS_WITH_THE_SAME_NAME_SHOULD_HAVE_THE_SAME_SET_OF_LOW_CARDINALITY_KEYS);
181+
}
182+
else {
183+
this.capabilities
184+
.remove(OBSERVATIONS_WITH_THE_SAME_NAME_SHOULD_HAVE_THE_SAME_SET_OF_LOW_CARDINALITY_KEYS);
185+
}
186+
return this;
187+
}
188+
189+
public TestObservationRegistry build() {
190+
return new TestObservationRegistry(capabilities);
191+
}
192+
193+
}
194+
195+
enum Capability {
196+
197+
OBSERVATIONS_WITH_THE_SAME_NAME_SHOULD_HAVE_THE_SAME_SET_OF_LOW_CARDINALITY_KEYS;
198+
199+
}
200+
133201
static class TestObservationContext {
134202

135203
private final Observation.Context context;

micrometer-observation-test/src/test/java/io/micrometer/observation/tck/ObservationValidatorTests.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,73 @@ void nullObservationShouldBeIgnored() {
234234
new NullObservation(registry).openScope();
235235
}
236236

237+
@Test
238+
void capabilitiesCanBeDisabledUsingTheBuilder() {
239+
TestObservationRegistry registry = TestObservationRegistry.builder()
240+
.validateObservationsWithTheSameNameHavingTheSameSetOfLowCardinalityKeys(false)
241+
.build();
242+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key1", "value1").start().stop();
243+
Observation.createNotStarted("test", registry).start().stop();
244+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key2", "value2").start().stop();
245+
}
246+
247+
@Test
248+
void observationsWithTheSameNameShouldHaveTheSameSetOfLowCardinalityKeysByDefaultUsingCreate() {
249+
TestObservationRegistry registry = TestObservationRegistry.create();
250+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key1", "value1").start().stop();
251+
assertThatThrownBy(() -> {
252+
Observation.createNotStarted("test", registry).start().stop();
253+
}).isExactlyInstanceOf(InvalidObservationException.class)
254+
.hasMessageContaining(
255+
"Metrics backends may require that all observations with the same name have the same set of low cardinality keys.");
256+
257+
assertThatThrownBy(() -> {
258+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key2", "value2").start().stop();
259+
}).isExactlyInstanceOf(InvalidObservationException.class)
260+
.hasMessageContaining(
261+
"Metrics backends may require that all observations with the same name have the same set of low cardinality keys.");
262+
263+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key1", "value2").start().stop();
264+
}
265+
266+
@Test
267+
void observationsWithTheSameNameShouldHaveTheSameSetOfLowCardinalityKeysByDefaultUsingTheBuilder() {
268+
TestObservationRegistry registry = TestObservationRegistry.builder().build();
269+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key1", "value1").start().stop();
270+
assertThatThrownBy(() -> {
271+
Observation.createNotStarted("test", registry).start().stop();
272+
}).isExactlyInstanceOf(InvalidObservationException.class)
273+
.hasMessageContaining(
274+
"Metrics backends may require that all observations with the same name have the same set of low cardinality keys.");
275+
276+
assertThatThrownBy(() -> {
277+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key2", "value2").start().stop();
278+
}).isExactlyInstanceOf(InvalidObservationException.class)
279+
.hasMessageContaining(
280+
"Metrics backends may require that all observations with the same name have the same set of low cardinality keys.");
281+
282+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key1", "value2").start().stop();
283+
}
284+
285+
@Test
286+
void observationsWithTheSameNameShouldHaveTheSameSetOfLowCardinalityKeysWhenEnabledUsingTheBuilder() {
287+
TestObservationRegistry registry = TestObservationRegistry.builder()
288+
.validateObservationsWithTheSameNameHavingTheSameSetOfLowCardinalityKeys(true)
289+
.build();
290+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key1", "value1").start().stop();
291+
assertThatThrownBy(() -> {
292+
Observation.createNotStarted("test", registry).start().stop();
293+
}).isExactlyInstanceOf(InvalidObservationException.class)
294+
.hasMessageContaining(
295+
"Metrics backends may require that all observations with the same name have the same set of low cardinality keys.");
296+
297+
assertThatThrownBy(() -> {
298+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key2", "value2").start().stop();
299+
}).isExactlyInstanceOf(InvalidObservationException.class)
300+
.hasMessageContaining(
301+
"Metrics backends may require that all observations with the same name have the same set of low cardinality keys.");
302+
303+
Observation.createNotStarted("test", registry).lowCardinalityKeyValue("key1", "value2").start().stop();
304+
}
305+
237306
}

0 commit comments

Comments
 (0)