1
0
mirror of synced 2026-09-11 02:19:47 +00:00

Add Micrometer Observation support. (#3249)

Signed-off-by: Maryanto <54889592+maryantocinn@users.noreply.github.com>
This commit is contained in:
Maryanto
2026-03-08 00:20:30 +07:00
committed by GitHub
parent 6e86bb2892
commit 9852589000
14 changed files with 1421 additions and 130 deletions
+7 -1
View File
@@ -355,7 +355,13 @@
<scope>test</scope>
</dependency>
</dependencies>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
@@ -0,0 +1,70 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import io.micrometer.common.KeyValues;
/**
* Default {@link ElasticsearchObservationConvention} implementation.
*
* @author maryantocinn
* @since 6.1
*/
public class DefaultElasticsearchObservationConvention implements ElasticsearchObservationConvention {
public static final DefaultElasticsearchObservationConvention INSTANCE = new DefaultElasticsearchObservationConvention();
@Override
public String getName() {
return ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.getName();
}
@Override
public String getContextualName(ElasticsearchObservationContext context) {
String indexName = context.getIndexName();
if (indexName != null) {
return context.getOperationName().getValue() + " " + indexName;
}
return context.getOperationName().getValue();
}
@Override
public KeyValues getLowCardinalityKeyValues(ElasticsearchObservationContext context) {
KeyValues keyValues = KeyValues.of(
ElasticsearchObservation.LowCardinalityKeyNames.OPERATION.withValue(context.getOperationName().getValue()));
String indexName = context.getIndexName();
if (indexName != null) {
keyValues = keyValues.and(ElasticsearchObservation.LowCardinalityKeyNames.COLLECTION.withValue(indexName));
}
return keyValues;
}
@Override
public KeyValues getHighCardinalityKeyValues(ElasticsearchObservationContext context) {
Integer batchSize = context.getBatchSize();
if (batchSize != null) {
return KeyValues.of(
ElasticsearchObservation.HighCardinalityKeyNames.BATCH_SIZE.withValue(String.valueOf(batchSize)));
}
return KeyValues.empty();
}
}
@@ -0,0 +1,99 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.docs.ObservationDocumentation;
/**
* {@link ObservationDocumentation} for Spring Data Elasticsearch template operations.
*
* @author maryantocinn
* @since 6.1
*/
public enum ElasticsearchObservation implements ObservationDocumentation {
/**
* Timer created around a Spring Data Elasticsearch template operation.
*/
ELASTICSEARCH_COMMAND_OBSERVATION {
@Override
public String getName() {
return "spring.data.elasticsearch.command";
}
@Override
public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultElasticsearchObservationConvention.class;
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return LowCardinalityKeyNames.values();
}
@Override
public KeyName[] getHighCardinalityKeyNames() {
return HighCardinalityKeyNames.values();
}
};
/**
* Low cardinality key names for Spring Data Elasticsearch observations. These become metric dimensions and MUST be
* present on every observation to satisfy backends like Prometheus that require consistent tag key sets.
*/
enum LowCardinalityKeyNames implements KeyName {
/**
* The Spring Data operation being performed (e.g., save, search, delete, bulk).
*/
OPERATION {
@Override
public String asString() {
return "spring.data.operation";
}
},
/**
* The target collection (index) name. Only present when the operation targets a specific index.
*/
COLLECTION {
@Override
public String asString() {
return "spring.data.collection";
}
}
}
/**
* High cardinality key names for Spring Data Elasticsearch observations. These appear only on traces/spans, not on
* metrics, because their values are unbounded or optional per operation.
*/
enum HighCardinalityKeyNames implements KeyName {
/**
* The number of operations included in a batch (bulk) request. Only present for bulk operations.
*/
BATCH_SIZE {
@Override
public String asString() {
return "spring.data.batch.size";
}
}
}
}
@@ -0,0 +1,83 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import org.jspecify.annotations.Nullable;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import io.micrometer.observation.Observation;
/**
* {@link Observation.Context} for Spring Data Elasticsearch operations. One instance is created per observed operation.
* It carries contextual data that conventions use to produce observation names and key-values.
*
* @author maryantocinn
* @since 6.1
*/
public class ElasticsearchObservationContext extends Observation.Context {
private final ElasticsearchOperationName operationName;
@Nullable
private final IndexCoordinates indexCoordinates;
@Nullable
private Integer batchSize;
public ElasticsearchObservationContext(ElasticsearchOperationName operationName,
@Nullable IndexCoordinates indexCoordinates) {
this.operationName = operationName;
this.indexCoordinates = indexCoordinates;
}
/**
* @return the Spring Data operation being performed.
*/
public ElasticsearchOperationName getOperationName() {
return operationName;
}
/**
* @return the target index coordinates, or {@literal null} if the operation is not index-specific.
*/
@Nullable
public IndexCoordinates getIndexCoordinates() {
return indexCoordinates;
}
/**
* @return the comma-joined index name(s), or {@literal null} if no index coordinates are set.
*/
@Nullable
public String getIndexName() {
return indexCoordinates != null ? String.join(",", indexCoordinates.getIndexNames()) : null;
}
/**
* @return the batch size, or {@literal null} if not a batch operation.
*/
@Nullable
public Integer getBatchSize() {
return batchSize;
}
/**
* Set the number of operations included in a batch (bulk) request.
*
* @param batchSize the batch size, can be {@literal null}
*/
public void setBatchSize(@Nullable Integer batchSize) {
this.batchSize = batchSize;
}
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
/**
* {@link ObservationConvention} for Spring Data Elasticsearch operations. Implement this interface and register it as a
* bean to customize observation names and key-values.
*
* @author maryantocinn
* @since 6.1
*/
public interface ElasticsearchObservationConvention extends ObservationConvention<ElasticsearchObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof ElasticsearchObservationContext;
}
}
@@ -0,0 +1,56 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
/**
* Enumeration of Spring Data Elasticsearch operation names used in observations.
*
* @author maryantocinn
* @since 6.1
*/
public enum ElasticsearchOperationName {
SAVE("save"), //
INDEX("index"), //
GET("get"), //
MULTI_GET("multiGet"), //
EXISTS("exists"), //
DELETE("delete"), //
DELETE_BY_QUERY("deleteByQuery"), //
BULK("bulk"), //
UPDATE("update"), //
UPDATE_BY_QUERY("updateByQuery"), //
COUNT("count"), //
SEARCH("search");
private final String value;
ElasticsearchOperationName(String value) {
this.value = value;
}
/**
* @return the operation name as a string value used in observation key values.
*/
public String getValue() {
return value;
}
@Override
public String toString() {
return value;
}
}
@@ -17,30 +17,11 @@ package org.springframework.data.elasticsearch.client.elc;
import static org.springframework.data.elasticsearch.client.elc.TypeUtils.*;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.Time;
import co.elastic.clients.elasticsearch.core.*;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import co.elastic.clients.elasticsearch.core.msearch.MultiSearchResponseItem;
import co.elastic.clients.elasticsearch.core.search.ResponseBody;
import co.elastic.clients.elasticsearch.sql.ElasticsearchSqlClient;
import co.elastic.clients.elasticsearch.sql.QueryResponse;
import co.elastic.clients.json.JsonpMapper;
import co.elastic.clients.transport.Version;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.data.elasticsearch.BulkFailureException;
import org.springframework.data.elasticsearch.client.UnsupportedBackendOperation;
import org.springframework.data.elasticsearch.core.AbstractElasticsearchTemplate;
@@ -62,6 +43,30 @@ import org.springframework.data.elasticsearch.core.script.Script;
import org.springframework.data.elasticsearch.core.sql.SqlResponse;
import org.springframework.util.Assert;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.Time;
import co.elastic.clients.elasticsearch.core.*;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import co.elastic.clients.elasticsearch.core.msearch.MultiSearchResponseItem;
import co.elastic.clients.elasticsearch.core.search.ResponseBody;
import co.elastic.clients.elasticsearch.sql.ElasticsearchSqlClient;
import co.elastic.clients.elasticsearch.sql.QueryResponse;
import co.elastic.clients.json.JsonpMapper;
import co.elastic.clients.transport.Version;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
/**
* Implementation of {@link org.springframework.data.elasticsearch.core.ElasticsearchOperations} using the new
* Elasticsearch client.
@@ -70,12 +75,15 @@ import org.springframework.util.Assert;
* @author Hamid Rahimi
* @author Illia Ulianov
* @author Haibo Liu
* @author maryantocinn
* @since 4.4
*/
public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
private static final Log LOGGER = LogFactory.getLog(ElasticsearchTemplate.class);
@Nullable private ElasticsearchObservationConvention observationConvention;
private final ElasticsearchClient client;
private final ElasticsearchSqlClient sqlClient;
private final RequestConverter requestConverter;
@@ -113,6 +121,61 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
protected AbstractElasticsearchTemplate doCopy() {
return new ElasticsearchTemplate(client, elasticsearchConverter);
}
@Override
protected void customizeCopy(AbstractElasticsearchTemplate copy) {
if (copy instanceof ElasticsearchTemplate elasticsearchTemplate) {
elasticsearchTemplate.observationConvention = this.observationConvention;
}
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
super.setApplicationContext(applicationContext);
if (observationRegistry == ObservationRegistry.NOOP) {
applicationContext.getBeanProvider(ObservationRegistry.class).ifAvailable(this::setObservationRegistry);
}
if (observationConvention == null) {
applicationContext.getBeanProvider(ElasticsearchObservationConvention.class)
.ifAvailable(this::setObservationConvention);
}
}
/**
* Set a custom {@link ElasticsearchObservationConvention} to override the default convention.
*
* @param observationConvention can be {@literal null}.
* @since 6.1
*/
public void setObservationConvention(@Nullable ElasticsearchObservationConvention observationConvention) {
this.observationConvention = observationConvention;
}
private <T> T observe(ElasticsearchOperationName operationName, @Nullable IndexCoordinates index,
Supplier<T> action) {
Observation observation = createObservation(operationName, index, null);
return observation.observe(action);
}
private <T> T observe(ElasticsearchOperationName operationName, @Nullable IndexCoordinates index, int batchSize,
Supplier<T> action) {
Observation observation = createObservation(operationName, index, batchSize);
return observation.observe(action);
}
private Observation createObservation(ElasticsearchOperationName operationName, @Nullable IndexCoordinates index,
@Nullable Integer batchSize) {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(operationName, index);
context.setBatchSize(batchSize);
return ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(observationConvention,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, observationRegistry);
}
// endregion
// region child templates
@@ -137,16 +200,45 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
// endregion
// region document operations
@Override
public <T> T save(T entity, IndexCoordinates index) {
return observe(ElasticsearchOperationName.SAVE, index, () -> super.save(entity, index));
}
@Override
public String index(IndexQuery query, IndexCoordinates index) {
return observe(ElasticsearchOperationName.INDEX, index, () -> super.index(query, index));
}
@Override
public boolean exists(String id, IndexCoordinates index) {
return observe(ElasticsearchOperationName.EXISTS, index, () -> super.exists(id, index));
}
@Override
public String delete(String id, IndexCoordinates index) {
return observe(ElasticsearchOperationName.DELETE, index, () -> super.delete(id, index));
}
@Override
public List<IndexedObjectInformation> bulkOperation(List<?> queries, BulkOptions bulkOptions,
IndexCoordinates index) {
return observe(ElasticsearchOperationName.BULK, index, queries.size(),
() -> super.bulkOperation(queries, bulkOptions, index));
}
@Override
@Nullable
public <T> T get(String id, Class<T> clazz, IndexCoordinates index) {
GetRequest getRequest = requestConverter.documentGetRequest(elasticsearchConverter.convertId(id),
routingResolver.getRouting(), index);
GetResponse<EntityAsMap> getResponse = execute(client -> client.get(getRequest, EntityAsMap.class));
return observe(ElasticsearchOperationName.GET, index, () -> {
GetRequest getRequest = requestConverter.documentGetRequest(elasticsearchConverter.convertId(id),
routingResolver.getRouting(), index);
GetResponse<EntityAsMap> getResponse = execute(client -> client.get(getRequest, EntityAsMap.class));
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(elasticsearchConverter, clazz, index);
return callback.doWith(DocumentAdapters.from(getResponse));
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(elasticsearchConverter, clazz, index);
return callback.doWith(DocumentAdapters.from(getResponse));
});
}
@Override
@@ -155,15 +247,17 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
Assert.notNull(query, "query must not be null");
Assert.notNull(clazz, "clazz must not be null");
MgetRequest request = requestConverter.documentMgetRequest(query, clazz, index);
MgetResponse<EntityAsMap> result = execute(client -> client.mget(request, EntityAsMap.class));
return observe(ElasticsearchOperationName.MULTI_GET, index, () -> {
MgetRequest request = requestConverter.documentMgetRequest(query, clazz, index);
MgetResponse<EntityAsMap> result = execute(client -> client.mget(request, EntityAsMap.class));
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(elasticsearchConverter, clazz, index);
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(elasticsearchConverter, clazz, index);
return DocumentAdapters.from(result).stream() //
.map(multiGetItem -> MultiGetItem.of( //
multiGetItem.isFailed() ? null : callback.doWith(multiGetItem.getItem()), multiGetItem.getFailure())) //
.collect(Collectors.toList());
return DocumentAdapters.from(result).stream() //
.map(multiGetItem -> MultiGetItem.of( //
multiGetItem.isFailed() ? null : callback.doWith(multiGetItem.getItem()), multiGetItem.getFailure())) //
.collect(Collectors.toList());
});
}
@Override
@@ -185,22 +279,26 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
public ByQueryResponse delete(DeleteQuery query, Class<?> clazz, IndexCoordinates index) {
Assert.notNull(query, "query must not be null");
DeleteByQueryRequest request = requestConverter.documentDeleteByQueryRequest(query, routingResolver.getRouting(),
clazz, index, getRefreshPolicy());
return observe(ElasticsearchOperationName.DELETE_BY_QUERY, index, () -> {
DeleteByQueryRequest request = requestConverter.documentDeleteByQueryRequest(query, routingResolver.getRouting(),
clazz, index, getRefreshPolicy());
DeleteByQueryResponse response = execute(client -> client.deleteByQuery(request));
DeleteByQueryResponse response = execute(client -> client.deleteByQuery(request));
return responseConverter.byQueryResponse(response);
return responseConverter.byQueryResponse(response);
});
}
@Override
public UpdateResponse update(UpdateQuery updateQuery, IndexCoordinates index) {
UpdateRequest<Document, ?> request = requestConverter.documentUpdateRequest(updateQuery, index, getRefreshPolicy(),
routingResolver.getRouting());
co.elastic.clients.elasticsearch.core.UpdateResponse<Document> response = execute(
client -> client.update(request, Document.class));
return UpdateResponse.of(result(response.result()));
return observe(ElasticsearchOperationName.UPDATE, index, () -> {
UpdateRequest<Document, ?> request = requestConverter.documentUpdateRequest(updateQuery, index,
getRefreshPolicy(), routingResolver.getRouting());
co.elastic.clients.elasticsearch.core.UpdateResponse<Document> response = execute(
client -> client.update(request, Document.class));
return UpdateResponse.of(result(response.result()));
});
}
@Override
@@ -209,11 +307,13 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
Assert.notNull(updateQuery, "updateQuery must not be null");
Assert.notNull(index, "index must not be null");
UpdateByQueryRequest request = requestConverter.documentUpdateByQueryRequest(updateQuery, index,
getRefreshPolicy());
return observe(ElasticsearchOperationName.UPDATE_BY_QUERY, index, () -> {
UpdateByQueryRequest request = requestConverter.documentUpdateByQueryRequest(updateQuery, index,
getRefreshPolicy());
UpdateByQueryResponse byQueryResponse = execute(client -> client.updateByQuery(request));
return responseConverter.byQueryResponse(byQueryResponse);
UpdateByQueryResponse byQueryResponse = execute(client -> client.updateByQuery(request));
return responseConverter.byQueryResponse(byQueryResponse);
});
}
@Override
@@ -328,12 +428,14 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
Assert.notNull(query, "query must not be null");
Assert.notNull(index, "index must not be null");
SearchRequest searchRequest = requestConverter.searchRequest(query, routingResolver.getRouting(), clazz, index,
true);
return observe(ElasticsearchOperationName.COUNT, index, () -> {
SearchRequest searchRequest = requestConverter.searchRequest(query, routingResolver.getRouting(), clazz, index,
true);
SearchResponse<EntityAsMap> searchResponse = execute(client -> client.search(searchRequest, EntityAsMap.class));
SearchResponse<EntityAsMap> searchResponse = execute(client -> client.search(searchRequest, EntityAsMap.class));
return searchResponse.hits().total().value();
return searchResponse.hits().total().value();
});
}
@Override
@@ -343,11 +445,13 @@ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate {
Assert.notNull(clazz, "clazz must not be null");
Assert.notNull(index, "index must not be null");
if (query instanceof SearchTemplateQuery searchTemplateQuery) {
return doSearch(searchTemplateQuery, clazz, index);
} else {
return doSearch(query, clazz, index);
}
return observe(ElasticsearchOperationName.SEARCH, index, () -> {
if (query instanceof SearchTemplateQuery searchTemplateQuery) {
return doSearch(searchTemplateQuery, clazz, index);
} else {
return doSearch(query, clazz, index);
}
});
}
protected <T> SearchHits<T> doSearch(Query query, Class<T> clazz, IndexCoordinates index) {
@@ -18,31 +18,13 @@ package org.springframework.data.elasticsearch.client.elc;
import static co.elastic.clients.util.ApiTypeHelper.*;
import static org.springframework.data.elasticsearch.client.elc.TypeUtils.*;
import co.elastic.clients.elasticsearch._types.Result;
import co.elastic.clients.elasticsearch.core.*;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import co.elastic.clients.elasticsearch.core.search.ResponseBody;
import co.elastic.clients.json.JsonpMapper;
import co.elastic.clients.transport.Version;
import co.elastic.clients.transport.endpoints.BooleanResponse;
import org.jspecify.annotations.NonNull;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
import java.time.Duration;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.reactivestreams.Publisher;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.data.domain.Sort;
import org.springframework.data.elasticsearch.BulkFailureException;
import org.springframework.data.elasticsearch.NoSuchIndexException;
@@ -53,6 +35,7 @@ import org.springframework.data.elasticsearch.core.AggregationContainer;
import org.springframework.data.elasticsearch.core.IndexedObjectInformation;
import org.springframework.data.elasticsearch.core.MultiGetItem;
import org.springframework.data.elasticsearch.core.ReactiveIndexOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.cluster.ReactiveClusterOperations;
import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter;
import org.springframework.data.elasticsearch.core.document.Document;
@@ -69,6 +52,28 @@ import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import co.elastic.clients.elasticsearch._types.Result;
import co.elastic.clients.elasticsearch.core.*;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import co.elastic.clients.elasticsearch.core.search.ResponseBody;
import co.elastic.clients.json.JsonpMapper;
import co.elastic.clients.transport.Version;
import co.elastic.clients.transport.endpoints.BooleanResponse;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
/**
* Implementation of {@link org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations} using the new
* Elasticsearch client.
@@ -76,12 +81,15 @@ import org.springframework.util.StringUtils;
* @author Peter-Josef Meisch
* @author Illia Ulianov
* @author Junghoon Ban
* @author maryantocinn
* @since 4.4
*/
public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearchTemplate {
private static final Log LOGGER = LogFactory.getLog(ReactiveElasticsearchTemplate.class);
@Nullable private ElasticsearchObservationConvention observationConvention;
private final ReactiveElasticsearchClient client;
private final ReactiveElasticsearchSqlClient sqlClient;
private final RequestConverter requestConverter;
@@ -102,7 +110,105 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
exceptionTranslator = new ElasticsearchExceptionTranslator(jsonpMapper);
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
super.setApplicationContext(applicationContext);
if (observationRegistry == ObservationRegistry.NOOP) {
applicationContext.getBeanProvider(ObservationRegistry.class).ifAvailable(this::setObservationRegistry);
}
if (observationConvention == null) {
applicationContext.getBeanProvider(ElasticsearchObservationConvention.class)
.ifAvailable(this::setObservationConvention);
}
}
/**
* Set a custom {@link ElasticsearchObservationConvention} to override the default convention.
*
* @param observationConvention can be {@literal null}.
* @since 6.1
*/
public void setObservationConvention(@Nullable ElasticsearchObservationConvention observationConvention) {
this.observationConvention = observationConvention;
}
private <T> Mono<T> observeMono(ElasticsearchOperationName operationName, @Nullable IndexCoordinates index,
Mono<T> mono) {
return Mono.defer(() -> {
Observation observation = createObservation(operationName, index, null);
return mono.doOnError(observation::error) //
.doFinally(signalType -> observation.stop())
.contextWrite(context -> context.put(Observation.class, observation))
.doOnSubscribe(subscription -> observation.start());
});
}
private <T> Flux<T> observeFlux(ElasticsearchOperationName operationName, @Nullable IndexCoordinates index,
Flux<T> flux) {
return Flux.defer(() -> {
Observation observation = createObservation(operationName, index, null);
return flux.doOnError(observation::error) //
.doFinally(signalType -> observation.stop())
.contextWrite(context -> context.put(Observation.class, observation))
.doOnSubscribe(subscription -> observation.start());
});
}
private <T> Mono<T> observeMono(ElasticsearchOperationName operationName, @Nullable IndexCoordinates index,
int batchSize, Mono<T> mono) {
return Mono.defer(() -> {
Observation observation = createObservation(operationName, index, batchSize);
return mono.doOnError(observation::error) //
.doFinally(signalType -> observation.stop())
.contextWrite(context -> context.put(Observation.class, observation))
.doOnSubscribe(subscription -> observation.start());
});
}
private Observation createObservation(ElasticsearchOperationName operationName, @Nullable IndexCoordinates index,
@Nullable Integer batchSize) {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(operationName, index);
context.setBatchSize(batchSize);
return ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(observationConvention,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, observationRegistry);
}
// region Document operations
@Override
public <T> Mono<T> save(T entity, IndexCoordinates index) {
return observeMono(ElasticsearchOperationName.SAVE, index, super.save(entity, index));
}
@Override
public Mono<Boolean> exists(String id, IndexCoordinates index) {
return observeMono(ElasticsearchOperationName.EXISTS, index, super.exists(id, index));
}
@Override
public Mono<String> delete(Object entity, IndexCoordinates index) {
return observeMono(ElasticsearchOperationName.DELETE, index, super.delete(entity, index));
}
@Override
public Mono<String> delete(String id, IndexCoordinates index) {
return observeMono(ElasticsearchOperationName.DELETE, index, super.delete(id, index));
}
@Override
public <T> Flux<SearchHit<T>> search(Query query, Class<?> entityType, Class<T> resultType, IndexCoordinates index) {
return observeFlux(ElasticsearchOperationName.SEARCH, index, super.search(query, entityType, resultType, index));
}
@Override
public Mono<Long> count(Query query, Class<?> entityType, IndexCoordinates index) {
return observeMono(ElasticsearchOperationName.COUNT, index, super.count(query, entityType, index));
}
@Override
protected <T> Mono<Tuple2<T, IndexResponseMetaData>> doIndex(T entity, IndexCoordinates index) {
@@ -124,7 +230,7 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
Assert.notNull(entitiesPublisher, "entitiesPublisher must not be null!");
return entitiesPublisher //
return observeFlux(ElasticsearchOperationName.BULK, index, entitiesPublisher //
.flatMapMany(entities -> Flux.fromIterable(entities) //
.concatMap(entity -> maybeCallbackBeforeConvert(entity, index)) //
).collectList() //
@@ -146,12 +252,12 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
response.index(), //
response.seqNo(), //
response.primaryTerm(), //
response.version()),
converter,
response.version()), //
converter, //
routingResolver);
return maybeCallbackAfterSave(updatedEntity, index);
});
});
}));
}
@Override
@@ -172,9 +278,11 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
public Mono<ByQueryResponse> delete(DeleteQuery query, Class<?> entityType, IndexCoordinates index) {
Assert.notNull(query, "query must not be null");
DeleteByQueryRequest request = requestConverter.documentDeleteByQueryRequest(query, routingResolver.getRouting(),
entityType, index, getRefreshPolicy());
return Mono.from(execute(client -> client.deleteByQuery(request))).map(responseConverter::byQueryResponse);
return observeMono(ElasticsearchOperationName.DELETE_BY_QUERY, index, Mono.defer(() -> {
DeleteByQueryRequest request = requestConverter.documentDeleteByQueryRequest(query, routingResolver.getRouting(),
entityType, index, getRefreshPolicy());
return Mono.from(execute(client -> client.deleteByQuery(request))).map(responseConverter::byQueryResponse);
}));
}
@Override
@@ -184,13 +292,15 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
Assert.notNull(entityType, "entityType must not be null");
Assert.notNull(index, "index must not be null");
GetRequest getRequest = requestConverter.documentGetRequest(id, routingResolver.getRouting(), index);
return observeMono(ElasticsearchOperationName.GET, index, Mono.defer(() -> {
GetRequest getRequest = requestConverter.documentGetRequest(id, routingResolver.getRouting(), index);
Mono<GetResponse<EntityAsMap>> getResponse = Mono
.from(execute(client -> client.get(getRequest, EntityAsMap.class)));
Mono<GetResponse<EntityAsMap>> getResponse = Mono //
.from(execute(client -> client.get(getRequest, EntityAsMap.class)));
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(converter, entityType, index);
return getResponse.flatMap(response -> callback.toEntity(DocumentAdapters.from(response)));
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(converter, entityType, index);
return getResponse.flatMap(response -> callback.toEntity(DocumentAdapters.from(response)));
}));
}
@Override
@@ -227,13 +337,15 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
Assert.notNull(updateQuery, "UpdateQuery must not be null");
Assert.notNull(index, "Index must not be null");
UpdateRequest<Document, ?> request = requestConverter.documentUpdateRequest(updateQuery, index, getRefreshPolicy(),
routingResolver.getRouting());
return observeMono(ElasticsearchOperationName.UPDATE, index, Mono.defer(() -> {
UpdateRequest<Document, ?> request = requestConverter.documentUpdateRequest(updateQuery, index,
getRefreshPolicy(), routingResolver.getRouting());
return Mono.from(execute(client -> client.update(request, Document.class))).flatMap(response -> {
UpdateResponse.Result result = result(response.result());
return result == null ? Mono.empty() : Mono.just(UpdateResponse.of(result));
});
return Mono.from(execute(client -> client.update(request, Document.class))).flatMap(response -> {
UpdateResponse.Result result = result(response.result());
return result == null ? Mono.empty() : Mono.just(UpdateResponse.of(result));
});
}));
}
@Override
@@ -248,7 +360,8 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
Assert.notNull(bulkOptions, "BulkOptions must not be null");
Assert.notNull(index, "Index must not be null");
return doBulkOperation(queries, bulkOptions, index).then();
return observeMono(ElasticsearchOperationName.BULK, index, queries.size(),
doBulkOperation(queries, bulkOptions, index).then());
}
private Flux<BulkResponseItem> doBulkOperation(List<?> queries, BulkOptions bulkOptions, IndexCoordinates index) {
@@ -311,22 +424,24 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
Assert.notNull(query, "query must not be null");
Assert.notNull(clazz, "clazz must not be null");
MgetRequest request = requestConverter.documentMgetRequest(query, clazz, index);
return observeFlux(ElasticsearchOperationName.MULTI_GET, index, Flux.defer(() -> {
MgetRequest request = requestConverter.documentMgetRequest(query, clazz, index);
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(converter, clazz, index);
ReadDocumentCallback<T> callback = new ReadDocumentCallback<>(converter, clazz, index);
Publisher<MgetResponse<EntityAsMap>> response = execute(client -> client.mget(request, EntityAsMap.class));
Publisher<MgetResponse<EntityAsMap>> response = execute(client -> client.mget(request, EntityAsMap.class));
return Mono.from(response)//
.flatMapMany(it -> Flux.fromIterable(DocumentAdapters.from(it))) //
.flatMap(multiGetItem -> {
if (multiGetItem.isFailed()) {
return Mono.just(MultiGetItem.of(null, multiGetItem.getFailure()));
} else {
return callback.toEntity(multiGetItem.getItem()) //
.map(t -> MultiGetItem.of(t, multiGetItem.getFailure()));
}
});
return Mono.from(response)//
.flatMapMany(it -> Flux.fromIterable(DocumentAdapters.from(it))) //
.flatMap(multiGetItem -> {
if (multiGetItem.isFailed()) {
return Mono.just(MultiGetItem.of(null, multiGetItem.getFailure()));
} else {
return callback.toEntity(multiGetItem.getItem()) //
.map(t -> MultiGetItem.of(t, multiGetItem.getFailure()));
}
});
}));
}
// endregion
@@ -336,6 +451,14 @@ public class ReactiveElasticsearchTemplate extends AbstractReactiveElasticsearch
return new ReactiveElasticsearchTemplate(client, converter);
}
@Override
protected void customizeCopy(AbstractReactiveElasticsearchTemplate copy) {
if (copy instanceof ReactiveElasticsearchTemplate reactiveTemplate) {
reactiveTemplate.observationConvention = this.observationConvention;
}
}
// region search operations
@Override
@@ -15,15 +15,6 @@
*/
package org.springframework.data.elasticsearch.core;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
@@ -43,7 +34,6 @@ import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersiste
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext;
import org.springframework.data.elasticsearch.core.query.BulkOptions;
import org.springframework.data.elasticsearch.core.query.ByQueryResponse;
import org.springframework.data.elasticsearch.core.query.IndexQuery;
import org.springframework.data.elasticsearch.core.query.IndexQueryBuilder;
import org.springframework.data.elasticsearch.core.query.MoreLikeThisQuery;
@@ -61,6 +51,17 @@ import org.springframework.data.util.Streamable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import io.micrometer.observation.ObservationRegistry;
/**
* This class contains methods that are common to different implementations of the {@link ElasticsearchOperations}
* interface that use different clients, like the different Java clients from Elasticsearch or some external
@@ -85,6 +86,7 @@ public abstract class AbstractElasticsearchTemplate implements ElasticsearchOper
@Nullable protected EntityCallbacks entityCallbacks;
@Nullable protected RefreshPolicy refreshPolicy;
protected RoutingResolver routingResolver;
protected ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
public AbstractElasticsearchTemplate() {
this(null);
@@ -117,6 +119,8 @@ public abstract class AbstractElasticsearchTemplate implements ElasticsearchOper
copy.setRoutingResolver(routingResolver);
copy.setRefreshPolicy(refreshPolicy);
copy.setObservationRegistry(observationRegistry);
customizeCopy(copy);
return copy;
}
@@ -171,6 +175,28 @@ public abstract class AbstractElasticsearchTemplate implements ElasticsearchOper
return refreshPolicy;
}
/**
* Set the {@link ObservationRegistry} to use for recording observations.
*
* @param observationRegistry must not be {@literal null}.
* @since 6.1
*/
public void setObservationRegistry(ObservationRegistry observationRegistry) {
Assert.notNull(observationRegistry, "observationRegistry must not be null");
this.observationRegistry = observationRegistry;
}
/**
* Hook for subclasses to copy additional state during {@link #copy()}. Called after all common fields have been
* copied. The default implementation does nothing.
*
* @param copy the new template instance to customize
*/
protected void customizeCopy(AbstractElasticsearchTemplate copy) {
}
/**
* logs the versions of the different Elasticsearch components.
*
@@ -15,17 +15,6 @@
*/
package org.springframework.data.elasticsearch.core;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.util.function.Tuple2;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
@@ -59,6 +48,18 @@ import org.springframework.data.elasticsearch.support.VersionInfo;
import org.springframework.data.mapping.callback.ReactiveEntityCallbacks;
import org.springframework.util.Assert;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import io.micrometer.observation.ObservationRegistry;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.util.function.Tuple2;
/**
* Base class keeping common code for implementations of the {@link ReactiveElasticsearchOperations} interface
* independent of the used client.
@@ -77,6 +78,7 @@ abstract public class AbstractReactiveElasticsearchTemplate
protected RoutingResolver routingResolver;
protected @Nullable ReactiveEntityCallbacks entityCallbacks;
protected ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
// region Initialization
protected AbstractReactiveElasticsearchTemplate(@Nullable ElasticsearchConverter converter) {
@@ -109,6 +111,8 @@ abstract public class AbstractReactiveElasticsearchTemplate
}
copy.setRoutingResolver(routingResolver);
copy.setObservationRegistry(observationRegistry);
customizeCopy(copy);
return copy;
}
@@ -162,6 +166,28 @@ abstract public class AbstractReactiveElasticsearchTemplate
this.entityCallbacks = entityCallbacks;
}
/**
* Set the {@link ObservationRegistry} to use for recording observations.
*
* @param observationRegistry must not be {@literal null}.
* @since 6.1
*/
public void setObservationRegistry(ObservationRegistry observationRegistry) {
Assert.notNull(observationRegistry, "observationRegistry must not be null");
this.observationRegistry = observationRegistry;
}
/**
* Hook for subclasses to copy additional state during {@link #copy()}. Called after all common fields have been
* copied. The default implementation does nothing.
*
* @param copy the new template instance to customize
*/
protected void customizeCopy(AbstractReactiveElasticsearchTemplate copy) {
}
/**
* logs the versions of the different Elasticsearch components.
*
@@ -0,0 +1,202 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import io.micrometer.observation.Observation;
/**
* Unit tests for {@link DefaultElasticsearchObservationConvention}.
*
* @author maryantocinn
*/
class DefaultElasticsearchObservationConventionTests {
private final DefaultElasticsearchObservationConvention convention = DefaultElasticsearchObservationConvention.INSTANCE;
@Test
@DisplayName("should return observation name matching ObservationDocumentation")
void shouldReturnCorrectName() {
assertThat(convention.getName()).isEqualTo("spring.data.elasticsearch.command");
}
@Test
@DisplayName("should support ElasticsearchObservationContext")
void shouldSupportElasticsearchContext() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("my-index"));
assertThat(convention.supportsContext(context)).isTrue();
}
@Test
@DisplayName("should not support unrelated context")
void shouldNotSupportUnrelatedContext() {
Observation.Context otherContext = new Observation.Context();
assertThat(convention.supportsContext(otherContext)).isFalse();
}
// region contextual name
@Test
@DisplayName("contextual name should be 'operation index' when index is present")
void contextualNameWithIndex() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("products"));
assertThat(convention.getContextualName(context)).isEqualTo("search products");
}
@Test
@DisplayName("contextual name should be just the operation when index is null")
void contextualNameWithoutIndex() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
null);
assertThat(convention.getContextualName(context)).isEqualTo("search");
}
@Test
@DisplayName("contextual name should include comma-joined indices for multi-index operations")
void contextualNameWithMultipleIndices() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("index-a", "index-b"));
assertThat(convention.getContextualName(context)).isEqualTo("search index-a,index-b");
}
// endregion
// region low cardinality key values
@Test
@DisplayName("should always include spring.data.operation")
void shouldIncludeRequiredKeyValues() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.GET, null);
KeyValues keyValues = convention.getLowCardinalityKeyValues(context);
assertThat(keyValues).contains(KeyValue.of("spring.data.operation", "get"));
}
@Test
@DisplayName("should include spring.data.collection when index is present")
void shouldIncludeCollectionWhenIndexPresent() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("products"));
KeyValues keyValues = convention.getLowCardinalityKeyValues(context);
assertThat(keyValues).contains(KeyValue.of("spring.data.collection", "products"));
}
@Test
@DisplayName("should not include spring.data.collection when index is null")
void shouldNotIncludeCollectionWhenNull() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
null);
KeyValues keyValues = convention.getLowCardinalityKeyValues(context);
assertThat(keyValues.stream().map(KeyValue::getKey)).doesNotContain("spring.data.collection");
}
@Test
@DisplayName("should include spring.data.batch.size as high cardinality when batch size is set")
void shouldIncludeBatchSizeAsHighCardinality() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.BULK,
IndexCoordinates.of("products"));
context.setBatchSize(5);
KeyValues highCardValues = convention.getHighCardinalityKeyValues(context);
assertThat(highCardValues).contains(KeyValue.of("spring.data.batch.size", "5"));
}
@Test
@DisplayName("should not include spring.data.batch.size in low cardinality key values")
void shouldNotIncludeBatchSizeInLowCardinality() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.BULK,
IndexCoordinates.of("products"));
context.setBatchSize(5);
KeyValues keyValues = convention.getLowCardinalityKeyValues(context);
assertThat(keyValues.stream().map(KeyValue::getKey)).doesNotContain("spring.data.batch.size");
}
@Test
@DisplayName("should return empty high cardinality key values when batch size is null")
void shouldReturnEmptyHighCardinalityWhenNoBatchSize() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("products"));
KeyValues highCardValues = convention.getHighCardinalityKeyValues(context);
assertThat(highCardValues.stream().map(KeyValue::getKey)).doesNotContain("spring.data.batch.size");
}
@Test
@DisplayName("should produce correct key values for a full bulk operation")
void shouldProduceCorrectKeyValuesForBulk() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.BULK,
IndexCoordinates.of("orders"));
context.setBatchSize(100);
KeyValues lowCardValues = convention.getLowCardinalityKeyValues(context);
KeyValues highCardValues = convention.getHighCardinalityKeyValues(context);
assertThat(lowCardValues).contains(KeyValue.of("spring.data.operation", "bulk"),
KeyValue.of("spring.data.collection", "orders"));
assertThat(highCardValues).contains(KeyValue.of("spring.data.batch.size", "100"));
}
@Test
@DisplayName("should produce correct key values for each operation name")
void shouldProduceCorrectOperationNames() {
for (ElasticsearchOperationName operationName : ElasticsearchOperationName.values()) {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(operationName,
IndexCoordinates.of("test-index"));
KeyValues keyValues = convention.getLowCardinalityKeyValues(context);
assertThat(keyValues).contains(KeyValue.of("spring.data.operation", operationName.getValue()));
}
}
// endregion
}
@@ -0,0 +1,91 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
/**
* Unit tests for {@link ElasticsearchObservationContext}.
*
* @author maryantocinn
*/
class ElasticsearchObservationContextTests {
@Test
@DisplayName("should carry operation name and index coordinates")
void shouldCarryOperationNameAndIndex() {
IndexCoordinates index = IndexCoordinates.of("my-index");
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
index);
assertThat(context.getOperationName()).isEqualTo(ElasticsearchOperationName.SEARCH);
assertThat(context.getIndexCoordinates()).isEqualTo(index);
assertThat(context.getIndexName()).isEqualTo("my-index");
}
@Test
@DisplayName("should return null index name when index coordinates are null")
void shouldReturnNullIndexNameWhenNull() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
null);
assertThat(context.getIndexCoordinates()).isNull();
assertThat(context.getIndexName()).isNull();
}
@Test
@DisplayName("should join multiple index names with comma")
void shouldJoinMultipleIndexNames() {
IndexCoordinates index = IndexCoordinates.of("index-a", "index-b", "index-c");
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
index);
assertThat(context.getIndexName()).isEqualTo("index-a,index-b,index-c");
}
@Test
@DisplayName("should store and retrieve batch size")
void shouldStoreAndRetrieveBatchSize() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.BULK,
IndexCoordinates.of("my-index"));
assertThat(context.getBatchSize()).isNull();
context.setBatchSize(42);
assertThat(context.getBatchSize()).isEqualTo(42);
}
@Test
@DisplayName("should allow null batch size")
void shouldAllowNullBatchSize() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.BULK,
IndexCoordinates.of("my-index"));
context.setBatchSize(10);
context.setBatchSize(null);
assertThat(context.getBatchSize()).isNull();
}
}
@@ -0,0 +1,207 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import io.micrometer.common.docs.KeyName;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
/**
* Tests for {@link ElasticsearchObservation} and the end-to-end observation lifecycle.
*
* @author maryantocinn
*/
class ElasticsearchObservationDocumentationTests {
@Test
@DisplayName("observation name should be 'spring.data.elasticsearch.command'")
void shouldHaveCorrectObservationName() {
assertThat(ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.getName()).isEqualTo(
"spring.data.elasticsearch.command");
}
@Test
@DisplayName("default convention should be DefaultElasticsearchObservationConvention")
void shouldHaveCorrectDefaultConvention() {
assertThat(ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.getDefaultConvention()).isEqualTo(
DefaultElasticsearchObservationConvention.class);
}
@Test
@DisplayName("should declare all expected low cardinality key names")
void shouldDeclareExpectedLowCardinalityKeyNames() {
KeyName[] keyNames = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.getLowCardinalityKeyNames();
List<String> keyStrings = Arrays.stream(keyNames).map(KeyName::asString).collect(Collectors.toList());
assertThat(keyStrings).containsExactlyInAnyOrder("spring.data.operation", "spring.data.collection");
}
@Test
@DisplayName("should declare spring.data.batch.size as high cardinality key name")
void shouldDeclareHighCardinalityKeyNames() {
KeyName[] keyNames = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.getHighCardinalityKeyNames();
List<String> keyStrings = Arrays.stream(keyNames).map(KeyName::asString).collect(Collectors.toList());
assertThat(keyStrings).containsExactly("spring.data.batch.size");
}
@Test
@DisplayName("should record observation with correct key values using TestObservationRegistry")
void shouldRecordObservationWithKeyValues() {
TestObservationRegistry registry = TestObservationRegistry.create();
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("products"));
Observation observation = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(null,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, registry);
observation.start();
observation.stop();
TestObservationRegistryAssert.assertThat(registry).doesNotHaveAnyRemainingCurrentObservation()
.hasObservationWithNameEqualTo("spring.data.elasticsearch.command").that()
.hasLowCardinalityKeyValue("spring.data.operation", "search")
.hasLowCardinalityKeyValue("spring.data.collection", "products").hasContextualNameEqualTo("search products");
}
@Test
@DisplayName("should record observation without index when null")
void shouldRecordObservationWithoutIndex() {
TestObservationRegistry registry = TestObservationRegistry.create();
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.DELETE,
null);
Observation observation = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(null,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, registry);
observation.start();
observation.stop();
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualTo("spring.data.elasticsearch.command").that()
.hasLowCardinalityKeyValue("spring.data.operation", "delete")
.doesNotHaveLowCardinalityKeyValue("spring.data.collection", "products").hasContextualNameEqualTo("delete");
}
@Test
@DisplayName("should record observation with batch size for bulk operations")
void shouldRecordObservationWithBatchSize() {
TestObservationRegistry registry = TestObservationRegistry.create();
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.BULK,
IndexCoordinates.of("logs"));
context.setBatchSize(25);
Observation observation = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(null,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, registry);
observation.start();
observation.stop();
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualTo("spring.data.elasticsearch.command").that()
.hasLowCardinalityKeyValue("spring.data.operation", "bulk")
.hasHighCardinalityKeyValue("spring.data.batch.size", "25").hasContextualNameEqualTo("bulk logs");
}
@Test
@DisplayName("should record error on observation")
void shouldRecordErrorOnObservation() {
TestObservationRegistry registry = TestObservationRegistry.create();
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("products"));
Observation observation = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(null,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, registry);
RuntimeException error = new RuntimeException("connection refused");
observation.start();
observation.error(error);
observation.stop();
TestObservationRegistryAssert.assertThat(registry)
.hasObservationWithNameEqualTo("spring.data.elasticsearch.command").that()
.hasLowCardinalityKeyValue("spring.data.operation", "search").hasBeenStopped();
}
@Test
@DisplayName("NOOP registry should not record any observations")
void noopRegistryShouldNotRecord() {
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("products"));
Observation observation = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(null,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, ObservationRegistry.NOOP);
observation.start();
observation.stop();
assertThat(observation.isNoop()).isTrue();
}
@Test
@DisplayName("custom convention should override default key values")
void customConventionShouldOverride() {
TestObservationRegistry registry = TestObservationRegistry.create();
ElasticsearchObservationConvention customConvention = new ElasticsearchObservationConvention() {
@Override
public String getName() {
return "custom.elasticsearch.command";
}
@Override
public String getContextualName(ElasticsearchObservationContext context) {
return "custom " + context.getOperationName().getValue();
}
};
ElasticsearchObservationContext context = new ElasticsearchObservationContext(ElasticsearchOperationName.SEARCH,
IndexCoordinates.of("products"));
Observation observation = ElasticsearchObservation.ELASTICSEARCH_COMMAND_OBSERVATION.observation(customConvention,
DefaultElasticsearchObservationConvention.INSTANCE, () -> context, registry);
observation.start();
observation.stop();
TestObservationRegistryAssert.assertThat(registry).hasObservationWithNameEqualTo("custom.elasticsearch.command")
.that().hasContextualNameEqualTo("custom search");
}
}
@@ -0,0 +1,164 @@
/*
* Copyright 2026-present 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.elasticsearch.client.elc;
import static org.assertj.core.api.Assertions.*;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration;
import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest;
import org.springframework.data.elasticsearch.utils.IndexNameProvider;
import org.springframework.test.context.ContextConfiguration;
/**
* Integration tests verifying that Micrometer observations are recorded for Spring Data Elasticsearch template
* operations when a {@link TestObservationRegistry} is wired into the application context.
*
* @author maryantocinn
* @since 6.1
*/
@SpringIntegrationTest
@ContextConfiguration(classes = { ObservabilityIntegrationTests.Config.class })
@DisplayName("Observability Integration Tests")
class ObservabilityIntegrationTests {
@Configuration
@Import({ ElasticsearchTemplateConfiguration.class })
static class Config {
@Bean
IndexNameProvider indexNameProvider() {
return new IndexNameProvider("observability-it");
}
@Bean
TestObservationRegistry observationRegistry() {
return TestObservationRegistry.create();
}
}
@Autowired private ElasticsearchOperations operations;
@Autowired private IndexNameProvider indexNameProvider;
@Autowired private TestObservationRegistry observationRegistry;
@BeforeEach
void setUp() {
indexNameProvider.increment();
operations.indexOps(SampleEntity.class).createWithMapping();
observationRegistry.clear();
}
@Test
@Order(Integer.MAX_VALUE)
void cleanup() {
operations.indexOps(IndexCoordinates.of(indexNameProvider.getPrefix() + "*")).delete();
}
@Test
@DisplayName("should record observation for save operation")
void shouldRecordObservationForSave() {
SampleEntity entity = new SampleEntity();
entity.setId("1");
entity.setMessage("hello");
operations.save(entity);
TestObservationRegistryAssert.assertThat(observationRegistry)
.hasObservationWithNameEqualTo("spring.data.elasticsearch.command")
.that()
.hasLowCardinalityKeyValue("spring.data.operation", "save")
.hasBeenStopped();
}
@Test
@DisplayName("should record observation for search operation")
void shouldRecordObservationForSearch() {
SampleEntity entity = new SampleEntity();
entity.setId("1");
entity.setMessage("hello");
operations.save(entity);
observationRegistry.clear();
SearchHits<SampleEntity> hits = operations.search(operations.matchAllQuery(), SampleEntity.class);
assertThat(hits.getTotalHits()).isGreaterThanOrEqualTo(1);
TestObservationRegistryAssert.assertThat(observationRegistry)
.hasObservationWithNameEqualTo("spring.data.elasticsearch.command")
.that()
.hasLowCardinalityKeyValue("spring.data.operation", "search")
.hasBeenStopped();
}
@Test
@DisplayName("should record observation with collection name")
void shouldRecordObservationWithCollectionName() {
SampleEntity entity = new SampleEntity();
entity.setId("1");
entity.setMessage("hello");
operations.save(entity);
TestObservationRegistryAssert.assertThat(observationRegistry)
.hasObservationWithNameEqualTo("spring.data.elasticsearch.command")
.that()
.hasLowCardinalityKeyValue("spring.data.collection", indexNameProvider.indexName());
}
@Document(indexName = "#{@indexNameProvider.indexName()}")
static class SampleEntity {
@Nullable @Id private String id;
@Nullable @Field(type = FieldType.Text) private String message;
@Nullable
public String getId() {
return id;
}
public void setId(@Nullable String id) {
this.id = id;
}
@Nullable
public String getMessage() {
return message;
}
public void setMessage(@Nullable String message) {
this.message = message;
}
}
}