Add DataLoader observability support

Prior to this commit, the `GraphQlObservationInstrumentation` would
instrument the following operations:

* GraphQL requests
* GraphQL data fetching operations

In the case of batch loading operations, the instrumentation would
consider each load call as a separate data fetching operation. This
would significantly clutter recorded traces and would make it look like
"N+1 problems" would still be present.

This commit adds a new "graphql.dataloader" observation for such
operations and avoids recording data fetching observations when
`SelfDescribingDataFetcher` declare that they call batch loading
operations.

Closes gh-1034
This commit is contained in:
Brian Clozel
2025-04-07 15:30:13 +02:00
parent 19a935fc18
commit 594c9dfc4f
7 changed files with 481 additions and 5 deletions

View File

@@ -81,3 +81,29 @@ By default, the following KeyValues are created:
|Name | Description
|`graphql.field.path` _(required)_|Path to the field being fetched (for example, "/bookById").
|===
[[observability.server.dataloader]]
== DataLoader instrumentation
GraphQL DataLoader observations are created with the name `"graphql.dataloader"`, observing calls to `@BatchMapping` controller methods and manually registered `DataLoader` instances.
Applications need to configure the `org.springframework.graphql.observation.GraphQlObservationInstrumentation` instrumentation in their application.
It is using the `org.springframework.graphql.observation.DefaultDataLoaderObservationConvention` by default, backed by the `DataLoaderObservationContext`.
By default, the following KeyValues are created:
.Low cardinality Keys
[cols="a,a"]
|===
|Name | Description
|`graphql.error.type` _(required)_|Class name of the data fetching error
|`graphql.loader.type` _(required)_|Class name of the elements being fetched.
|`graphql.outcome` _(required)_|Outcome of the GraphQL data fetching operation, "SUCCESS" or "ERROR".
|===
.High cardinality Keys
|===
|Name | Description
|`graphql.loader.size` _(required)_|Size of the list of loaded elements.
|===

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2020-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.graphql.observation;
import java.util.List;
import io.micrometer.observation.Observation;
import org.dataloader.BatchLoaderEnvironment;
/**
* Context that holds information for metadata collection during observations
* for {@link GraphQlObservationDocumentation#DATA_LOADER data loader operations}.
*
* @author Brian Clozel
* @since 1.4.0
*/
public class DataLoaderObservationContext extends Observation.Context {
private final List<?> keys;
private final BatchLoaderEnvironment environment;
private List<?> result = List.of();
DataLoaderObservationContext(List<?> keys, BatchLoaderEnvironment environment) {
this.keys = keys;
this.environment = environment;
}
/**
* Return the keys for loading by the {@link org.dataloader.DataLoader}.
*/
public List<?> getKeys() {
return this.keys;
}
/**
* Return the list of values resolved by the {@link org.dataloader.DataLoader},
* or an empty list if none were resolved.
*/
public List<?> getResult() {
return this.result;
}
/**
* Set the list of resolved values by the {@link org.dataloader.DataLoader}.
*/
public void setResult(List<?> result) {
this.result = result;
}
/**
* Return the {@link BatchLoaderEnvironment environment} given to the batch loading function.
*/
public BatchLoaderEnvironment getEnvironment() {
return this.environment;
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2020-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.graphql.observation;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
/**
* Interface for an {@link ObservationConvention}
* for {@link GraphQlObservationDocumentation#DATA_LOADER data loading observations}.
*
* @author Brian Clozel
* @since 1.4.0
*/
public interface DataLoaderObservationConvention extends ObservationConvention<DataLoaderObservationContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof DataLoaderObservationContext;
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2020-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.graphql.observation;
import java.util.List;
import java.util.Locale;
import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.graphql.observation.GraphQlObservationDocumentation.DataLoaderHighCardinalityKeyNames;
import org.springframework.graphql.observation.GraphQlObservationDocumentation.DataLoaderLowCardinalityKeyNames;
/**
* Default implementation for a {@link DataLoaderObservationConvention}
* extracting information from a {@link DataLoaderObservationContext}.
*
* @author Brian Clozel
* @since 1.4.0
*/
public class DefaultDataLoaderObservationConvention implements DataLoaderObservationConvention {
private static final String DEFAULT_NAME = "graphql.dataloader";
private static final KeyValue ERROR_TYPE_NONE = KeyValue.of(DataLoaderLowCardinalityKeyNames.ERROR_TYPE, "NONE");
private static final KeyValue LOADER_TYPE_UNKNOWN = KeyValue.of(DataLoaderLowCardinalityKeyNames.LOADER_TYPE, "unknown");
private static final KeyValue OUTCOME_SUCCESS = KeyValue.of(DataLoaderLowCardinalityKeyNames.OUTCOME, "SUCCESS");
private static final KeyValue OUTCOME_ERROR = KeyValue.of(DataLoaderLowCardinalityKeyNames.OUTCOME, "ERROR");
@Override
public String getName() {
return DEFAULT_NAME;
}
@Override
public String getContextualName(DataLoaderObservationContext context) {
List<?> result = context.getResult();
if (result.isEmpty()) {
return "graphql dataloader";
}
else {
return "graphql dataloader " + result.get(0).getClass().getSimpleName().toLowerCase(Locale.ROOT);
}
}
@Override
public KeyValues getLowCardinalityKeyValues(DataLoaderObservationContext context) {
return KeyValues.of(errorType(context), loaderType(context), outcome(context));
}
@Override
public KeyValues getHighCardinalityKeyValues(DataLoaderObservationContext context) {
return KeyValues.of(loaderSize(context));
}
protected KeyValue errorType(DataLoaderObservationContext context) {
if (context.getError() != null) {
return KeyValue.of(DataLoaderLowCardinalityKeyNames.ERROR_TYPE, context.getError().getClass().getSimpleName());
}
return ERROR_TYPE_NONE;
}
protected KeyValue loaderType(DataLoaderObservationContext context) {
if (context.getResult().isEmpty()) {
return LOADER_TYPE_UNKNOWN;
}
return KeyValue.of(DataLoaderLowCardinalityKeyNames.LOADER_TYPE, context.getResult().get(0).getClass().getSimpleName());
}
protected KeyValue outcome(DataLoaderObservationContext context) {
if (context.getError() != null) {
return OUTCOME_ERROR;
}
return OUTCOME_SUCCESS;
}
protected KeyValue loaderSize(DataLoaderObservationContext context) {
return KeyValue.of(DataLoaderHighCardinalityKeyNames.LOADER_SIZE, String.valueOf(context.getResult().size()));
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2022 the original author or authors.
* Copyright 2020-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.
@@ -79,6 +79,33 @@ public enum GraphQlObservationDocumentation implements ObservationDocumentation
public KeyName[] getLowCardinalityKeyNames() {
return DataFetcherLowCardinalityKeyNames.values();
}
},
/**
* Observation created for {@link org.dataloader.DataLoader} operations.
* @since 1.4.0
*/
DATA_LOADER {
@Override
public String getPrefix() {
return "graphql";
}
@Override
public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultDataLoaderObservationConvention.class;
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return DataLoaderLowCardinalityKeyNames.values();
}
@Override
public KeyName[] getHighCardinalityKeyNames() {
return DataLoaderHighCardinalityKeyNames.values();
}
};
public enum ExecutionRequestLowCardinalityKeyNames implements KeyName {
@@ -165,4 +192,52 @@ public enum GraphQlObservationDocumentation implements ObservationDocumentation
}
public enum DataLoaderLowCardinalityKeyNames implements KeyName {
/**
* Class name of the data fetching error.
*/
ERROR_TYPE {
@Override
public String asString() {
return "graphql.error.type";
}
},
/**
* {@link Class#getSimpleName()} of the returned elements.
*/
LOADER_TYPE {
@Override
public String asString() {
return "graphql.loader.type";
}
},
/**
* Outcome of the GraphQL data fetching operation.
*/
OUTCOME {
@Override
public String asString() {
return "graphql.outcome";
}
}
}
public enum DataLoaderHighCardinalityKeyNames implements KeyName {
/**
* Size of the list of elements returned by the data loading operation.
*/
LOADER_SIZE {
@Override
public String asString() {
return "graphql.loader.size";
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -16,10 +16,12 @@
package org.springframework.graphql.observation;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQLContext;
import graphql.execution.instrumentation.InstrumentationContext;
@@ -35,7 +37,14 @@ import graphql.schema.DataFetchingEnvironmentImpl;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
import org.dataloader.BatchLoaderEnvironment;
import org.dataloader.DataLoader;
import org.dataloader.DataLoaderRegistry;
import org.dataloader.instrumentation.DataLoaderInstrumentation;
import org.dataloader.instrumentation.DataLoaderInstrumentationContext;
import org.springframework.graphql.execution.SelfDescribingDataFetcher;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
/**
@@ -63,6 +72,9 @@ public class GraphQlObservationInstrumentation extends SimplePerformantInstrumen
private static final DataFetcherObservationConvention DEFAULT_DATA_FETCHER_CONVENTION =
new DefaultDataFetcherObservationConvention();
private static final DefaultDataLoaderObservationConvention DEFAULT_DATA_LOADER_CONVENTION =
new DefaultDataLoaderObservationConvention();
private final ObservationRegistry observationRegistry;
@Nullable
@@ -71,6 +83,9 @@ public class GraphQlObservationInstrumentation extends SimplePerformantInstrumen
@Nullable
private final DataFetcherObservationConvention dataFetcherObservationConvention;
@Nullable
private final DataLoaderObservationConvention dataLoaderObservationConvention;
/**
* Create an {@code GraphQlObservationInstrumentation} that records observations
* against the given {@link ObservationRegistry}. The default observation
@@ -78,7 +93,7 @@ public class GraphQlObservationInstrumentation extends SimplePerformantInstrumen
* @param observationRegistry the registry to use for recording observations
*/
public GraphQlObservationInstrumentation(ObservationRegistry observationRegistry) {
this(observationRegistry, null, null);
this(observationRegistry, null, null, null);
}
/**
@@ -87,13 +102,44 @@ public class GraphQlObservationInstrumentation extends SimplePerformantInstrumen
* @param observationRegistry the registry to use for recording observations
* @param requestObservationConvention the convention to use for request observations
* @param dateFetcherObservationConvention the convention to use for data fetcher observations
* @deprecated since 1.4.0 in favor of {@link #GraphQlObservationInstrumentation(ObservationRegistry,
* ExecutionRequestObservationConvention, DataFetcherObservationConvention, DataLoaderObservationConvention)}
*/
@Deprecated(since = "1.4.0", forRemoval = true)
public GraphQlObservationInstrumentation(ObservationRegistry observationRegistry,
@Nullable ExecutionRequestObservationConvention requestObservationConvention,
@Nullable DataFetcherObservationConvention dateFetcherObservationConvention) {
this(observationRegistry, requestObservationConvention, dateFetcherObservationConvention, null);
}
/**
* Create an {@code GraphQlObservationInstrumentation} that records observations
* against the given {@link ObservationRegistry} with a custom convention.
* @param observationRegistry the registry to use for recording observations
* @param requestObservationConvention the convention to use for request observations
* @param dateFetcherObservationConvention the convention to use for data fetcher observations
* @param dataLoaderObservationConvention the convention to use for data loader observations
* @since 1.4.0
*/
public GraphQlObservationInstrumentation(ObservationRegistry observationRegistry,
@Nullable ExecutionRequestObservationConvention requestObservationConvention,
@Nullable DataFetcherObservationConvention dateFetcherObservationConvention,
@Nullable DataLoaderObservationConvention dataLoaderObservationConvention) {
this.observationRegistry = observationRegistry;
this.requestObservationConvention = requestObservationConvention;
this.dataFetcherObservationConvention = dateFetcherObservationConvention;
this.dataLoaderObservationConvention = dataLoaderObservationConvention;
}
@Override
public @NonNull ExecutionInput instrumentExecutionInput(ExecutionInput executionInput, InstrumentationExecutionParameters parameters, InstrumentationState state) {
return executionInput.transform((builder) -> {
DataLoaderRegistry dataLoaderRegistry = DataLoaderRegistry.newRegistry()
.registerAll(executionInput.getDataLoaderRegistry())
.instrumentation(new ObservationDataLoaderInstrumentation())
.build();
builder.dataLoaderRegistry(dataLoaderRegistry);
});
}
@Override
@@ -112,7 +158,7 @@ public class GraphQlObservationInstrumentation extends SimplePerformantInstrumen
requestObservation.start();
return new SimpleInstrumentationContext<>() {
@Override
public void onCompleted(ExecutionResult result, Throwable exc) {
public void onCompleted(ExecutionResult result, @Nullable Throwable exc) {
observationContext.setExecutionResult(result);
result.getErrors().forEach((graphQLError) -> {
Observation.Event event = Observation.Event.of(graphQLError.getErrorType().toString(), graphQLError.getMessage());
@@ -139,6 +185,11 @@ public class GraphQlObservationInstrumentation extends SimplePerformantInstrumen
InstrumentationFieldFetchParameters parameters, InstrumentationState state) {
if (!parameters.isTrivialDataFetcher()
&& state == RequestObservationInstrumentationState.INSTANCE) {
// skip batch loading operations, already instrumented at the dataloader level
if (dataFetcher instanceof SelfDescribingDataFetcher<?> selfDescribingDataFetcher
&& selfDescribingDataFetcher.isBatchLoading()) {
return dataFetcher;
}
return (environment) -> {
DataFetcherObservationContext observationContext = new DataFetcherObservationContext(environment);
Observation dataFetcherObservation = GraphQlObservationDocumentation.DATA_FETCHER.observation(this.dataFetcherObservationConvention,
@@ -219,4 +270,37 @@ public class GraphQlObservationInstrumentation extends SimplePerformantInstrumen
}
class ObservationDataLoaderInstrumentation implements DataLoaderInstrumentation {
@Override
public DataLoaderInstrumentationContext<List<?>> beginBatchLoader(DataLoader<?, ?> dataLoader, List<?> keys, BatchLoaderEnvironment environment) {
Observation observation = GraphQlObservationDocumentation.DATA_LOADER
.observation(GraphQlObservationInstrumentation.this.dataLoaderObservationConvention,
DEFAULT_DATA_LOADER_CONVENTION,
() -> new DataLoaderObservationContext(keys, environment),
GraphQlObservationInstrumentation.this.observationRegistry);
if (environment.getContext() instanceof GraphQLContext graphQLContext) {
Observation parentObservation = graphQLContext.get(ObservationThreadLocalAccessor.KEY);
observation.parentObservation(parentObservation);
}
return new DataLoaderInstrumentationContext<List<?>>() {
@Override
public void onDispatched() {
observation.start();
}
@Override
public void onCompleted(List<?> result, @Nullable Throwable t) {
DataLoaderObservationContext context = (DataLoaderObservationContext) observation.getContext();
context.setResult(result);
if (t != null) {
observation.error(t);
}
observation.stop();
}
};
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -27,6 +27,7 @@ import graphql.GraphqlErrorBuilder;
import graphql.execution.DataFetcherResult;
import graphql.schema.AsyncDataFetcher;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationHandler;
@@ -34,12 +35,15 @@ import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.dataloader.DataLoader;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.ResolvableType;
import org.springframework.graphql.Author;
import org.springframework.graphql.Book;
import org.springframework.graphql.BookSource;
@@ -48,8 +52,11 @@ import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.GraphQlSetup;
import org.springframework.graphql.ResponseHelper;
import org.springframework.graphql.TestExecutionRequest;
import org.springframework.graphql.execution.BatchLoaderRegistry;
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.DefaultBatchLoaderRegistry;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.graphql.execution.SelfDescribingDataFetcher;
import static org.assertj.core.api.Assertions.assertThat;
@@ -357,6 +364,83 @@ class GraphQlObservationInstrumentationTests {
.hasHighCardinalityKeyValueWithKey("graphql.execution.id");
}
@Test
void shouldRecordBatchLoadingAsSingleObservation() {
String document = """
{
booksById(id: [1,2,3,4,5]) {
name
author {
firstName
}
}
}
""";
BatchLoaderRegistry registry = new DefaultBatchLoaderRegistry();
registry.forTypePair(Long.class, Author.class)
.registerBatchLoader((ids, env) -> Flux.fromIterable(ids).map(BookSource::getAuthor));
Mono<ExecutionGraphQlResponse> responseMono = graphQlSetup
.queryFetcher("booksById", (environment) -> {
List<String> id = environment.getArgument("id");
return Flux.fromIterable(id).map(Long::parseLong).map(BookSource::getBookWithoutAuthor).collectList();
})
.dataFetcher("Book", "author", new AuthorBatchLoadingDataFetcher())
.dataLoaders(registry)
.toGraphQlService()
.execute(document);
ResponseHelper response = ResponseHelper.forResponse(responseMono);
List<Book> booksById = response.toList("booksById", Book.class);
assertThat(booksById).hasSize(5);
TestObservationRegistryAssert.assertThat(this.observationRegistry).hasObservationWithNameEqualTo("graphql.request")
.that().hasLowCardinalityKeyValue("graphql.outcome", "SUCCESS")
.hasHighCardinalityKeyValueWithKey("graphql.execution.id");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasNumberOfObservationsWithNameEqualTo("graphql.datafetcher", 1)
.hasObservationWithNameEqualTo("graphql.datafetcher")
.that()
.hasLowCardinalityKeyValue("graphql.outcome", "SUCCESS")
.hasLowCardinalityKeyValue("graphql.field.name", "booksById")
.hasHighCardinalityKeyValue("graphql.field.path", "/booksById");
TestObservationRegistryAssert.assertThat(this.observationRegistry)
.hasNumberOfObservationsWithNameEqualTo("graphql.dataloader", 1)
.hasObservationWithNameEqualTo("graphql.dataloader")
.that()
.hasLowCardinalityKeyValue("graphql.outcome", "SUCCESS")
.hasLowCardinalityKeyValue("graphql.loader.type", "Author")
.hasHighCardinalityKeyValue("graphql.loader.size", "4")
.hasContextualNameEqualTo("graphql dataloader author");
}
static class AuthorBatchLoadingDataFetcher implements SelfDescribingDataFetcher<CompletableFuture<Author>> {
@Override
public boolean isBatchLoading() {
return true;
}
@Override
public String getDescription() {
return "BatchLoading authors";
}
@Override
public ResolvableType getReturnType() {
return ResolvableType.forClass(Author.class);
}
@Override
public CompletableFuture<Author> get(DataFetchingEnvironment env) throws Exception {
Book book = env.getSource();
DataLoader<Long, Author> dataLoader = env.getDataLoader(Author.class.getName());
return dataLoader.load(book.getAuthorId());
}
}
static class EventListeningObservationHandler implements ObservationHandler<ExecutionRequestObservationContext> {
private final List<Observation.Event> events = new ArrayList<>();