diff --git a/spring-graphql-docs/modules/ROOT/pages/observability.adoc b/spring-graphql-docs/modules/ROOT/pages/observability.adoc index 688dc011..60070ec7 100644 --- a/spring-graphql-docs/modules/ROOT/pages/observability.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/observability.adoc @@ -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. +|=== + diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/DataLoaderObservationContext.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataLoaderObservationContext.java new file mode 100644 index 00000000..42e71806 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataLoaderObservationContext.java @@ -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; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/DataLoaderObservationConvention.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataLoaderObservationConvention.java new file mode 100644 index 00000000..19f80870 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/DataLoaderObservationConvention.java @@ -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 { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof DataLoaderObservationContext; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultDataLoaderObservationConvention.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultDataLoaderObservationConvention.java new file mode 100644 index 00000000..8e184a8d --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/DefaultDataLoaderObservationConvention.java @@ -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())); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationDocumentation.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationDocumentation.java index 3d512a48..ee81a08b 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationDocumentation.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationDocumentation.java @@ -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> 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"; + } + } + + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationInstrumentation.java b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationInstrumentation.java index bdcba5ba..c8c3bb15 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationInstrumentation.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/observation/GraphQlObservationInstrumentation.java @@ -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> 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>() { + @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(); + } + }; + } + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java index e5c1546d..49b621be 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/observation/GraphQlObservationInstrumentationTests.java @@ -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 responseMono = graphQlSetup + .queryFetcher("booksById", (environment) -> { + List 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 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> { + + @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 get(DataFetchingEnvironment env) throws Exception { + Book book = env.getSource(); + DataLoader dataLoader = env.getDataLoader(Author.class.getName()); + return dataLoader.load(book.getAuthorId()); + } + } + static class EventListeningObservationHandler implements ObservationHandler { private final List events = new ArrayList<>();