diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/DefaultMongoHandlerObservationConvention.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/DefaultMongoHandlerObservationConvention.java index 4509fd7b2e..3d3742d577 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/DefaultMongoHandlerObservationConvention.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/DefaultMongoHandlerObservationConvention.java @@ -15,17 +15,11 @@ */ package org.springframework.data.mongodb.observability; -import io.micrometer.common.KeyValue; import io.micrometer.common.KeyValues; -import org.springframework.data.mongodb.observability.MongoObservation.LowCardinalityCommandKeyNames; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; -import com.mongodb.ConnectionString; -import com.mongodb.ServerAddress; -import com.mongodb.connection.ConnectionDescription; -import com.mongodb.connection.ConnectionId; import com.mongodb.event.CommandStartedEvent; /** @@ -41,59 +35,11 @@ class DefaultMongoHandlerObservationConvention implements MongoHandlerObservatio @Override public KeyValues getLowCardinalityKeyValues(MongoHandlerContext context) { - KeyValues keyValues = KeyValues.of(LowCardinalityCommandKeyNames.DB_SYSTEM.withValue("mongodb"), - LowCardinalityCommandKeyNames.MONGODB_COMMAND.withValue(context.getCommandName())); - - ConnectionString connectionString = context.getConnectionString(); - if (connectionString != null) { - - keyValues = keyValues - .and(LowCardinalityCommandKeyNames.DB_CONNECTION_STRING.withValue(connectionString.getConnectionString())); - - String user = connectionString.getUsername(); - - if (!ObjectUtils.isEmpty(user)) { - keyValues = keyValues.and(LowCardinalityCommandKeyNames.DB_USER.withValue(user)); - } - } - - if (!ObjectUtils.isEmpty(context.getDatabaseName())) { - keyValues = keyValues.and(LowCardinalityCommandKeyNames.DB_NAME.withValue(context.getDatabaseName())); - } - - keyValues = keyValues.and(LowCardinalityCommandKeyNames.MONGODB_COLLECTION.withValue( - ObjectUtils.isEmpty(context.getCollectionName()) ? KeyValue.NONE_VALUE : context.getCollectionName())); - if (context.getCommandStartedEvent() == null) { throw new IllegalStateException("not command started event present"); } - ConnectionDescription connectionDescription = context.getCommandStartedEvent().getConnectionDescription(); - - if (connectionDescription != null) { - - ServerAddress serverAddress = connectionDescription.getServerAddress(); - - if (serverAddress != null) { - - keyValues = keyValues.and(LowCardinalityCommandKeyNames.NET_TRANSPORT.withValue("IP.TCP"), - LowCardinalityCommandKeyNames.NET_PEER_NAME.withValue(serverAddress.getHost()), - LowCardinalityCommandKeyNames.NET_PEER_PORT.withValue("" + serverAddress.getPort())); - } - - ConnectionId connectionId = connectionDescription.getConnectionId(); - if (connectionId != null) { - keyValues = keyValues.and(LowCardinalityCommandKeyNames.MONGODB_CLUSTER_ID - .withValue(connectionId.getServerId().getClusterId().getValue())); - } - } - - return keyValues; - } - - @Override - public KeyValues getHighCardinalityKeyValues(MongoHandlerContext context) { - return KeyValues.empty(); + return MongoObservation.LowCardinality.observe(context).toKeyValues(); } @Override diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/MongoKeyName.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/MongoKeyName.java new file mode 100644 index 0000000000..b7db03fc3c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/MongoKeyName.java @@ -0,0 +1,178 @@ +/* + * Copyright 2025 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.mongodb.observability; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.docs.KeyName; + +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.StringUtils; + +/** + * Value object representing an observation key name for MongoDB operations. It allows easier transformation to + * {@link KeyValue} and {@link KeyName}. + * + * @author Mark Paluch + */ +record MongoKeyName(String name, boolean required, Function valueFunction) implements KeyName { + + /** + * Creates a required {@link MongoKeyName} along with a contextual value function to extract the value from the + * context. The value defaults to {@link KeyValue#NONE_VALUE} if the contextual value function returns + * {@literal null}. + * + * @param name + * @param valueFunction + * @return + * @param + */ + public static MongoKeyName required(String name, Function valueFunction) { + return required(name, valueFunction, Objects::nonNull); + } + + /** + * Creates a required {@link MongoKeyName} along with a contextual value function to extract the value from the + * context. The value defaults to {@link KeyValue#NONE_VALUE} if the contextual value function returns {@literal null} + * or an empty {@link String}. + * + * @param name + * @param valueFunction + * @return + * @param + */ + public static MongoKeyName requiredString(String name, Function valueFunction) { + return required(name, valueFunction, StringUtils::hasText); + } + + /** + * Creates a required {@link MongoKeyName} along with a contextual value function to extract the value from the + * context. The value defaults to {@link KeyValue#NONE_VALUE} if the contextual value function returns + * {@literal null}. + * + * @param name + * @param valueFunction + * @param hasValue predicate to determine if the value is present. + * @return + * @param + */ + public static MongoKeyName required(String name, Function valueFunction, + Predicate hasValue) { + return new MongoKeyName<>(name, true, c -> { + V value = valueFunction.apply(c); + return hasValue.test(value) ? value : null; + }); + } + + /** + * Creates a required {@link MongoKeyValue} with a constant value. + * + * @param name + * @param value + * @return + */ + public static MongoKeyValue just(String name, String value) { + return new MongoKeyName<>(name, false, it -> value).withValue(value); + } + + /** + * Create a new {@link MongoKeyValue} with a given value. + * + * @param value value for key + * @return + */ + @Override + public MongoKeyValue withValue(String value) { + return new MongoKeyValue(this, value); + } + + /** + * Create a new {@link MongoKeyValue} from the context. If the context is {@literal null}, the value will be + * {@link KeyValue#NONE_VALUE}. + * + * @param context + * @return + */ + public MongoKeyValue valueOf(@Nullable C context) { + + Object value = context != null ? valueFunction.apply(context) : null; + return new MongoKeyValue(this, value == null ? KeyValue.NONE_VALUE : value.toString()); + } + + /** + * Create a new absent {@link MongoKeyValue} with the {@link KeyValue#NONE_VALUE} as value. + * + * @return + */ + public MongoKeyValue absent() { + return new MongoKeyValue(this, KeyValue.NONE_VALUE); + } + + @Override + public boolean isRequired() { + return required; + } + + @Override + public String asString() { + return name; + } + + @Override + public String toString() { + return "Key: " + asString(); + } + + /** + * Value object representing an observation key and value for MongoDB operations. It allows easier transformation to + * {@link KeyValue} and {@link KeyName}. + */ + static class MongoKeyValue implements KeyName, KeyValue { + + private final KeyName keyName; + private final String value; + + MongoKeyValue(KeyName keyName, String value) { + this.keyName = keyName; + this.value = value; + } + + @Override + public String getKey() { + return keyName.asString(); + } + + @Override + public String getValue() { + return value; + } + + @Override + public String asString() { + return getKey(); + } + + @Override + public String toString() { + return getKey() + "=" + getValue(); + } + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/MongoObservation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/MongoObservation.java index 9dfc292521..b898def63e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/MongoObservation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/MongoObservation.java @@ -15,9 +15,20 @@ */ package org.springframework.data.mongodb.observability; +import static org.springframework.data.mongodb.observability.MongoKeyName.*; + import io.micrometer.common.docs.KeyName; import io.micrometer.observation.docs.ObservationDocumentation; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.StringUtils; + +import com.mongodb.ConnectionString; +import com.mongodb.ServerAddress; +import com.mongodb.connection.ConnectionDescription; +import com.mongodb.event.CommandEvent; + /** * A MongoDB-based {@link io.micrometer.observation.Observation}. * @@ -39,7 +50,7 @@ public String getName() { @Override public KeyName[] getLowCardinalityKeyNames() { - return LowCardinalityCommandKeyNames.values(); + return LowCardinality.getKeyNames(); } @Override @@ -50,128 +61,63 @@ public KeyName[] getHighCardinalityKeyNames() { }; /** - * Enums related to low cardinality key names for MongoDB commands. + * Contributors for low cardinality key names. */ - enum LowCardinalityCommandKeyNames implements KeyName { + static class LowCardinality { - /** - * MongoDB database system. - */ - DB_SYSTEM { - @Override - public String asString() { - return "db.system"; - } - }, + static MongoKeyValue DB_SYSTEM = just("db.system", "mongodb"); + static MongoKeyName MONGODB_COMMAND = MongoKeyName.requiredString("db.operation", + MongoHandlerContext::getCommandName); - /** - * MongoDB connection string. - */ - DB_CONNECTION_STRING { - @Override - public String asString() { - return "db.connection_string"; - } - }, - - /** - * Network transport. - */ - NET_TRANSPORT { - @Override - public String asString() { - return "net.transport"; - } - }, + static MongoKeyName DB_NAME = MongoKeyName.requiredString("db.name", + MongoHandlerContext::getDatabaseName); - /** - * Name of the database host. - */ - NET_PEER_NAME { - @Override - public String asString() { - return "net.peer.name"; - } - }, + static MongoKeyName MONGODB_COLLECTION = MongoKeyName.requiredString("db.mongodb.collection", + MongoHandlerContext::getCollectionName); /** - * Logical remote port number. + * MongoDB cluster identifier. */ - NET_PEER_PORT { - @Override - public String asString() { - return "net.peer.port"; - } - }, + static MongoKeyName MONGODB_CLUSTER_ID = MongoKeyName.required( + "spring.data.mongodb.cluster_id", it -> it.getConnectionId().getServerId().getClusterId().getValue(), + StringUtils::hasText); - /** - * Mongo peer address. - */ - NET_SOCK_PEER_ADDR { - @Override - public String asString() { - return "net.sock.peer.addr"; - } - }, + static MongoKeyValue NET_TRANSPORT_TCP_IP = just("net.transport", "IP.TCP"); + static MongoKeyName NET_PEER_NAME = MongoKeyName.required("net.peer.name", ServerAddress::getHost); + static MongoKeyName NET_PEER_PORT = MongoKeyName.required("net.peer.port", ServerAddress::getPort); - /** - * Mongo peer port. - */ - NET_SOCK_PEER_PORT { - @Override - public String asString() { - return "net.sock.peer.port"; - } - }, + static MongoKeyName DB_CONNECTION_STRING = MongoKeyName.requiredString("db.connection_string", + Object::toString); + static MongoKeyName DB_USER = MongoKeyName.requiredString("db.user", + ConnectionString::getUsername); /** - * MongoDB user. + * Observe low cardinality key values for the given {@link MongoHandlerContext}. + * + * @param context the context to contribute from, can be {@literal null} if no context is available. + * @return the key value contributor providing low cardinality key names. */ - DB_USER { - @Override - public String asString() { - return "db.user"; - } - }, + public static Observer observe(@Nullable MongoHandlerContext context) { - /** - * MongoDB database name. - */ - DB_NAME { - @Override - public String asString() { - return "db.name"; - } - }, + return Observer.fromContext(context, it -> { - /** - * MongoDB collection name. - */ - MONGODB_COLLECTION { - @Override - public String asString() { - return "db.mongodb.collection"; - } - }, + it.contribute(DB_SYSTEM).contribute(MONGODB_COMMAND, DB_NAME, MONGODB_COLLECTION); - /** - * MongoDB cluster identifier. - */ - MONGODB_CLUSTER_ID { - @Override - public String asString() { - return "spring.data.mongodb.cluster_id"; - } - }, + it.nested(MongoHandlerContext::getConnectionString).contribute(DB_CONNECTION_STRING, DB_USER); + it.nested(MongoHandlerContext::getCommandStartedEvent) // + .nested(CommandEvent::getConnectionDescription).contribute(MONGODB_CLUSTER_ID) // + .nested(ConnectionDescription::getServerAddress) // + .contribute(NET_TRANSPORT_TCP_IP).contribute(NET_PEER_NAME, NET_PEER_PORT); + }); + } /** - * MongoDB command value. + * Returns the key names for low cardinality keys. + * + * @return the key names for low cardinality keys. */ - MONGODB_COMMAND { - @Override - public String asString() { - return "db.operation"; - } + public static KeyName[] getKeyNames() { + return observe(null).toKeyNames(); } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/Observer.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/Observer.java new file mode 100644 index 0000000000..d0a414936c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/observability/Observer.java @@ -0,0 +1,281 @@ +/* + * Copyright 2025 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.mongodb.observability; + +import io.micrometer.common.KeyValues; +import io.micrometer.common.docs.KeyName; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +/** + * An observer abstraction that can observe a context and contribute {@literal KeyValue}s for propagation into + * observability systems. + * + * @author Mark Paluch + */ +class Observer { + + private final List keyValues = new ArrayList<>(); + + /** + * Create a new {@link Observer}. + * + * @return a new {@link Observer}. + */ + public static Observer create() { + return new Observer(); + } + + /** + * Create a new {@link Observer} given an optional context and a consumer that will contribute key-value tuples from + * the given context. + * + * @param context the context to observe, can be {@literal null}. + * @param consumer consumer for a functional declaration that supplies key-value tuples. + * @return the stateful {@link Observer}. + * @param context type. + */ + public static Observer fromContext(@Nullable C context, Consumer> consumer) { + + Observer contributor = create(); + + consumer.accept(contributor.contextual(context)); + + return contributor; + } + + /** + * Contribute a single {@link MongoKeyName.MongoKeyValue} to the observer. + * + * @param keyValue + * @return + */ + public Observer contribute(MongoKeyName.MongoKeyValue keyValue) { + + keyValues.add(keyValue); + + return this; + } + + /** + * Create a nested, contextual {@link ContextualObserver} that can contribute key-value tuples based on the given + * context. + * + * @param context the context to observe, can be {@literal null}. + * @return the nested contextual {@link ContextualObserver} that can contribute key-value tuples. + * @param + */ + public ContextualObserver contextual(@Nullable C context) { + + if (context == null) { + return new EmptyContextualObserver<>(keyValues); + } + + return new DefaultContextualObserver<>(context, keyValues); + } + + public ContextualObserver empty(Class targetType) { + return new EmptyContextualObserver<>(this.keyValues); + } + + public KeyValues toKeyValues() { + return KeyValues.of(keyValues); + } + + public KeyName[] toKeyNames() { + + KeyName[] keyNames = new KeyName[keyValues.size()]; + + for (int i = 0; i < keyValues.size(); i++) { + MongoKeyName.MongoKeyValue keyValue = keyValues.get(i); + keyNames[i] = keyValue; + } + + return keyNames; + } + + /** + * Contextual observer interface to contribute key-value tuples based on a context. The context can be transformed + * into a nested context using {@link #nested(Function)}. + * + * @param + */ + interface ContextualObserver { + + /** + * Create a nested {@link ContextualObserver} that can contribute key-value tuples based on the transformation of + * the current context. If the {@code mapper} function returns {@literal null}, the nested observer will operate + * without a context contributing {@literal MonKoKeyName.absent()} values simplifying nullability handling. + * + * @param mapper context mapper function that transforms the current context into a nested context. + * @return the nested contextual observer. + * @param nested context type. + */ + ContextualObserver nested(Function mapper); + + /** + * Functional-style contribution of a {@link ContextualObserver} callback. + * + * @param consumer the consumer that will be invoked with this {@link ContextualObserver}. + * @return {@code this} {@link ContextualObserver} for further chaining. + */ + default ContextualObserver contribute(Consumer> consumer) { + consumer.accept(this); + return this; + } + + /** + * Contribute a {@link MongoKeyName.MongoKeyValue} to the observer. + * + * @param keyValue + * @return {@code this} {@link ContextualObserver} for further chaining. + */ + ContextualObserver contribute(MongoKeyName.MongoKeyValue keyValue); + + /** + * Contribute a {@link MongoKeyName} to the observer. + * + * @param keyName + * @return {@code this} {@link ContextualObserver} for further chaining. + */ + default ContextualObserver contribute(MongoKeyName keyName) { + return contribute(List.of(keyName)); + } + + /** + * Contribute a collection of {@link MongoKeyName}s to the observer. + * + * @param keyName0 + * @param keyName1 + * @return {@code this} {@link ContextualObserver} for further chaining. + */ + default ContextualObserver contribute(MongoKeyName keyName0, MongoKeyName keyName1) { + return contribute(List.of(keyName0, keyName1)); + } + + /** + * Contribute a collection of {@link MongoKeyName}s to the observer. + * + * @param keyName0 + * @param keyName1 + * @param keyName2 + * @return {@code this} {@link ContextualObserver} for further chaining. + */ + default ContextualObserver contribute(MongoKeyName keyName0, MongoKeyName keyName1, + MongoKeyName keyName2) { + return contribute(List.of(keyName0, keyName1, keyName2)); + } + + /** + * Contribute a collection of {@link MongoKeyName}s to the observer. + * + * @param keyNames + * @return {@code this} {@link ContextualObserver} for further chaining. + */ + ContextualObserver contribute(Iterable> keyNames); + + } + + /** + * A default {@link ContextualObserver} that observes a target and contributes key-value tuples by providing the + * context to {@link MongoKeyName}. + * + * @param target + * @param keyValues + * @param + */ + private record DefaultContextualObserver(T target, + List keyValues) implements ContextualObserver { + + public ContextualObserver nested(Function mapper) { + + N nestedTarget = mapper.apply(target); + + if (nestedTarget == null) { + return new EmptyContextualObserver<>(keyValues); + } + + return new DefaultContextualObserver<>(nestedTarget, keyValues); + } + + @Override + public ContextualObserver contribute(MongoKeyName.MongoKeyValue keyValue) { + + keyValues.add(keyValue); + + return this; + } + + @Override + public ContextualObserver contribute(MongoKeyName keyName) { + + keyValues.add(keyName.valueOf(target)); + + return this; + } + + @Override + public ContextualObserver contribute(Iterable> keyNames) { + + for (MongoKeyName name : keyNames) { + keyValues.add(name.valueOf(target)); + } + + return this; + } + + } + + /** + * Empty {@link ContextualObserver} that is not associated with a context and therefore, it only contributes + * {@link MongoKeyName#absent()} values. + * + * @param keyValues + * @param + */ + private record EmptyContextualObserver( + List keyValues) implements ContextualObserver { + + public ContextualObserver nested(Function mapper) { + return new EmptyContextualObserver<>(keyValues); + } + + @Override + public ContextualObserver contribute(MongoKeyName.MongoKeyValue keyValue) { + + keyValues.add(keyValue); + + return this; + } + + @Override + public ContextualObserver contribute(Iterable> keyNames) { + + for (MongoKeyName name : keyNames) { + keyValues.add(name.absent()); + } + + return this; + } + + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/observability/MongoObservationCommandListenerTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/observability/MongoObservationCommandListenerTests.java index 35536e3921..fe74a03bd6 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/observability/MongoObservationCommandListenerTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/observability/MongoObservationCommandListenerTests.java @@ -31,7 +31,6 @@ import org.bson.BsonString; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.data.mongodb.observability.MongoObservation.LowCardinalityCommandKeyNames; import com.mongodb.ConnectionString; import com.mongodb.RequestContext; @@ -167,10 +166,10 @@ void successfullyCompletedCommandWithoutClusterInformationShouldCreateTimerWhenP listener.commandSucceeded(new CommandSucceededEvent(traceRequestContext, 0, 0, null, "insert", null, null, 0)); assertThat(meterRegistry).hasTimerWithNameAndTags(MongoObservation.MONGODB_COMMAND_OBSERVATION.getName(), - KeyValues.of(LowCardinalityCommandKeyNames.MONGODB_COLLECTION.withValue("user"), - LowCardinalityCommandKeyNames.DB_NAME.withValue("database"), - LowCardinalityCommandKeyNames.MONGODB_COMMAND.withValue("insert"), - LowCardinalityCommandKeyNames.DB_SYSTEM.withValue("mongodb")).and("error", "none")); + KeyValues.of(MongoObservation.LowCardinality.MONGODB_COLLECTION.withValue("user"), + MongoObservation.LowCardinality.DB_NAME.withValue("database"), + MongoObservation.LowCardinality.MONGODB_COMMAND.withValue("insert"), + MongoObservation.LowCardinality.DB_SYSTEM.withValue("mongodb")).and("error", "none")); } @Test @@ -260,7 +259,7 @@ private void assertThatTimerRegisteredWithTags() { assertThat(meterRegistry) // .hasTimerWithNameAndTags(MongoObservation.MONGODB_COMMAND_OBSERVATION.getName(), - KeyValues.of(LowCardinalityCommandKeyNames.MONGODB_COLLECTION.withValue("user"))); + KeyValues.of(MongoObservation.LowCardinality.MONGODB_COLLECTION.withValue("user"))); } }