diff --git a/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientBuildTimeConfig.java b/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientBuildTimeConfig.java index 8bb10a921b7dbe..c2332e66286031 100644 --- a/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientBuildTimeConfig.java +++ b/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientBuildTimeConfig.java @@ -24,6 +24,12 @@ public class MongoClientBuildTimeConfig { @ConfigItem(name = "force-default-clients") public boolean forceDefaultClients; + /** + * Whether or not tracing spans of driver commands are sent in case the quarkus-opentelemetry extension is present. + */ + @ConfigItem(name = "tracing.enabled") + public boolean tracingEnabled; + /** * Configuration for DevServices. DevServices allows Quarkus to automatically start MongoDB in dev and test mode. */ diff --git a/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java b/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java index 99da47258cee07..c5eed5ca6ef6d2 100644 --- a/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java +++ b/extensions/mongodb-client/deployment/src/main/java/io/quarkus/mongodb/deployment/MongoClientProcessor.java @@ -13,6 +13,7 @@ import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; +import java.util.stream.Stream; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Default; @@ -85,6 +86,8 @@ public class MongoClientProcessor { private static final DotName MONGO_CLIENT_CUSTOMIZER = DotName.createSimple(MongoClientCustomizer.class.getName()); + private static final String MONGODB_TRACING_COMMANDLISTENER_CLASSNAME = "io.quarkus.mongodb.tracing.MongoTracingCommandListener"; + private static final String SERVICE_BINDING_INTERFACE_NAME = "io.quarkus.kubernetes.service.binding.runtime.ServiceBindingConverter"; @BuildStep @@ -147,10 +150,14 @@ CommandListenerBuildItem collectCommandListeners(CombinedIndexBuildItem indexBui MongoClientBuildTimeConfig buildTimeConfig, Capabilities capabilities) { Collection commandListenerClasses = indexBuildItem.getIndex() .getAllKnownImplementors(DotName.createSimple(CommandListener.class.getName())); - List names = commandListenerClasses.stream() - .map(ci -> ci.name().toString()) - .collect(Collectors.toList()); - return new CommandListenerBuildItem(names); + Stream names = commandListenerClasses.stream() + .map(ci -> ci.name().toString()); + Stream tracing = Stream.empty(); + if (buildTimeConfig.tracingEnabled && capabilities.isPresent(Capability.OPENTELEMETRY_TRACER)) { + tracing = Stream.of(MONGODB_TRACING_COMMANDLISTENER_CLASSNAME); + } + var items = Stream.concat(names, tracing).toList(); + return new CommandListenerBuildItem(items); } @BuildStep diff --git a/extensions/mongodb-client/runtime/pom.xml b/extensions/mongodb-client/runtime/pom.xml index 5199ffcd380695..408d5efd302101 100644 --- a/extensions/mongodb-client/runtime/pom.xml +++ b/extensions/mongodb-client/runtime/pom.xml @@ -36,6 +36,11 @@ io.quarkus quarkus-mutiny-reactive-streams-operators + + io.quarkus + quarkus-opentelemetry + true + org.mongodb mongodb-driver-sync diff --git a/extensions/mongodb-client/runtime/src/main/java/io/quarkus/mongodb/tracing/MongoTracingCommandListener.java b/extensions/mongodb-client/runtime/src/main/java/io/quarkus/mongodb/tracing/MongoTracingCommandListener.java new file mode 100644 index 00000000000000..8dfa9cc1e91951 --- /dev/null +++ b/extensions/mongodb-client/runtime/src/main/java/io/quarkus/mongodb/tracing/MongoTracingCommandListener.java @@ -0,0 +1,95 @@ +package io.quarkus.mongodb.tracing; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import javax.annotation.Nullable; + +import org.jboss.logging.Logger; + +import com.mongodb.event.*; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.instrumentation.api.instrumenter.SpanKindExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.SpanNameExtractor; +import io.quarkus.arc.Arc; + +public class MongoTracingCommandListener implements CommandListener { + private static final org.jboss.logging.Logger LOGGER = Logger.getLogger(MongoTracingCommandListener.class); + private static final String KEY = "mongodb.command"; + private final Map requestMap; + private final Instrumenter instrumenter; + + private record ContextEvent(Context context, CommandStartedEvent commandEvent) { + } + + public MongoTracingCommandListener() { + var openTelemetry = getOpenTelemetry(); + requestMap = new ConcurrentHashMap<>(); + SpanNameExtractor spanNameExtractor = CommandEvent::getCommandName; + instrumenter = Instrumenter. builder( + openTelemetry, "quarkus-mongodb-client", spanNameExtractor) + .addAttributesExtractor(new CommandEventAttrExtractor()) + .buildInstrumenter(SpanKindExtractor.alwaysClient()); + LOGGER.debugf("MongoTracingCommandListener created"); + } + + OpenTelemetry getOpenTelemetry() { + return Arc.container().instance(OpenTelemetry.class).get(); + } + + @Override + public void commandStarted(CommandStartedEvent event) { + LOGGER.tracef("commandStarted event %s", event.getCommandName()); + + Context parentContext = Context.current(); + if (instrumenter.shouldStart(parentContext, event)) { + Context context = instrumenter.start(parentContext, event); + requestMap.put(event.getRequestId(), new ContextEvent(context, event)); + } + } + + @Override + public void commandSucceeded(CommandSucceededEvent event) { + LOGGER.tracef("commandSucceeded event %s", event.getCommandName()); + ContextEvent contextEvent = requestMap.remove(event.getRequestId()); + if (contextEvent != null) { + instrumenter.end(contextEvent.context(), contextEvent.commandEvent(), null, null); + } + } + + @Override + public void commandFailed(CommandFailedEvent event) { + LOGGER.tracef("commandFailed event %s", event.getCommandName()); + ContextEvent contextEvent = requestMap.remove(event.getRequestId()); + if (contextEvent != null) { + instrumenter.end( + contextEvent.context(), + contextEvent.commandEvent(), + null, + event.getThrowable()); + } + } + + private static class CommandEventAttrExtractor implements AttributesExtractor { + @Override + public void onStart(AttributesBuilder attributesBuilder, + Context context, + CommandStartedEvent commandStartedEvent) { + attributesBuilder.put(KEY, commandStartedEvent.getCommand().toJson()); + } + + @Override + public void onEnd(AttributesBuilder attributesBuilder, + Context context, + CommandStartedEvent commandStartedEvent, + @Nullable Void unused, + @Nullable Throwable throwable) { + + } + } +} diff --git a/extensions/mongodb-client/runtime/src/test/java/io/quarkus/mongodb/tracing/MongoTracingCommandListenerTest.java b/extensions/mongodb-client/runtime/src/test/java/io/quarkus/mongodb/tracing/MongoTracingCommandListenerTest.java new file mode 100644 index 00000000000000..2d331d22ebcc63 --- /dev/null +++ b/extensions/mongodb-client/runtime/src/test/java/io/quarkus/mongodb/tracing/MongoTracingCommandListenerTest.java @@ -0,0 +1,105 @@ +package io.quarkus.mongodb.tracing; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.bson.BsonDocument; +import org.junit.jupiter.api.Test; + +import com.mongodb.event.CommandFailedEvent; +import com.mongodb.event.CommandStartedEvent; +import com.mongodb.event.CommandSucceededEvent; + +import io.opentelemetry.api.OpenTelemetry; + +class MongoTracingCommandListenerTest { + + @Test + void commandStarted() { + var listener = getListener(); + var command = new BsonDocument(); + var startEvent = new CommandStartedEvent( + null, + 1L, + 10, + null, + "db", + "find", + command); + assertThatNoException().isThrownBy(() -> listener.commandStarted(startEvent)); + + CommandSucceededEvent successEvent = new CommandSucceededEvent(null, + startEvent.getOperationId(), + startEvent.getRequestId(), + null, + startEvent.getDatabaseName(), + startEvent.getCommandName(), + startEvent.getCommand(), + 10l); + assertThatNoException().isThrownBy(() -> listener.commandSucceeded(successEvent)); + } + + @Test + void commandSucceeded() { + var listener = getListener(); + var command = new BsonDocument(); + CommandSucceededEvent cmd = new CommandSucceededEvent(null, + 1L, + 10, + null, + "db", + "find", + command, + 10l); + assertThatNoException().isThrownBy(() -> listener.commandSucceeded(cmd)); + } + + @Test + void commandFailed() { + var listener = getListener(); + var command = new BsonDocument(); + var startedEvent = new CommandStartedEvent( + null, + 1L, + 10, + null, + "db", + "find", + command); + assertThatNoException().isThrownBy(() -> listener.commandStarted(startedEvent)); + + CommandFailedEvent failedEvent = new CommandFailedEvent(null, + 1L, + 10, + null, + "db", + "find", + 10L, + new IllegalStateException("command failed")); + assertThatNoException().isThrownBy(() -> listener.commandFailed(failedEvent)); + } + + @Test + void commandFailedNoEvent() { + var listener = getListener(); + CommandFailedEvent cmd = new CommandFailedEvent(null, + 1L, + 10, + null, + "db", + "find", + 10L, + new IllegalStateException("command failed")); + assertThatNoException().isThrownBy(() -> listener.commandFailed(cmd)); + } + + private static MongoTracingCommandListener getListener() { + return new TestMongoTracingCommandListener(); + } + + private static class TestMongoTracingCommandListener extends MongoTracingCommandListener { + @Override + OpenTelemetry getOpenTelemetry() { + return OpenTelemetry.noop(); + } + } +} diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/pom.xml b/integration-tests/opentelemetry-mongodb-client-instrumentation/pom.xml new file mode 100644 index 00000000000000..af52788c2d49fa --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/pom.xml @@ -0,0 +1,181 @@ + + + 4.0.0 + + + io.quarkus + quarkus-integration-tests-parent + 999-SNAPSHOT + + + quarkus-integration-test-opentelemetry-mongodb-client-instrumentation + Quarkus - Integration Tests - OpenTelemetry Mongo Db Client instrumentation + + + + io.quarkus + quarkus-mongodb-client + + + + io.quarkus + quarkus-opentelemetry + + + + + io.quarkus + quarkus-rest-jackson + + + + + io.opentelemetry + opentelemetry-sdk-testing + + + + + io.quarkus + quarkus-test-mongodb + test + + + + io.quarkus + quarkus-junit5 + test + + + org.assertj + assertj-core + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + org.testcontainers + testcontainers + test + + + + io.quarkus + quarkus-mongodb-client-deployment + ${project.version} + pom + test + + + * + * + + + + + + io.quarkus + quarkus-rest-jackson-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-opentelemetry-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + src/main/resources + true + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + native-image + + + native + + + + + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + ${native.surefire.skip} + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + + + + + diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/Book.java b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/Book.java new file mode 100644 index 00000000000000..c89aa0c5b03cd1 --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/Book.java @@ -0,0 +1,8 @@ +package io.quarkus.it.opentelemetry; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record Book(String author, String title) { + +} diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/BookResource.java b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/BookResource.java new file mode 100644 index 00000000000000..ed667a4ecb5ee9 --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/BookResource.java @@ -0,0 +1,70 @@ +package io.quarkus.it.opentelemetry; + +import static com.mongodb.client.model.Filters.eq; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; + +import org.bson.Document; + +import com.mongodb.WriteConcern; +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; + +import io.smallrye.common.annotation.Blocking; + +@Path("/books") +@Blocking +public class BookResource { + + @Inject + MongoClient client; + + private MongoCollection getCollection() { + return client.getDatabase("books").getCollection("my-collection", Book.class); + } + + @DELETE + public Response clearBooks() { + getCollection().deleteMany(new Document()); + return Response.ok().build(); + } + + @GET + public List getBooks() { + FindIterable iterable = getCollection().find(); + List books = new ArrayList<>(); + WriteConcern writeConcern = client.getDatabase("temp").getWriteConcern(); + // force a test failure if we're not getting the correct, and correctly configured named mongodb client + if (Boolean.TRUE.equals(writeConcern.getJournal())) { + for (Book doc : iterable) { + books.add(doc); + } + } + return books; + } + + @POST + public Response addBook(Book book) { + getCollection().insertOne(book); + return Response.accepted().build(); + } + + @GET + @Path("/{author}") + public List getBooksByAuthor(@PathParam("author") String author) { + FindIterable iterable = getCollection().find(eq("author", author)); + List books = new ArrayList<>(); + for (Book doc : iterable) { + String title = doc.title(); + books.add(new Book(author, title)); + } + return books; + } + +} diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/ExporterResource.java b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/ExporterResource.java new file mode 100644 index 00000000000000..a611fda7c2c7bc --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/ExporterResource.java @@ -0,0 +1,46 @@ +package io.quarkus.it.opentelemetry; + +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; + +@Path("") +public class ExporterResource { + @Inject + InMemorySpanExporter inMemorySpanExporter; + + @GET + @Path("/reset") + public Response reset() { + inMemorySpanExporter.reset(); + return Response.ok().build(); + } + + @GET + @Path("/export") + public List export() { + return inMemorySpanExporter.getFinishedSpanItems() + .stream() + .filter(sd -> !sd.getName().contains("export") && !sd.getName().contains("reset")) + .collect(Collectors.toList()); + } + + @ApplicationScoped + static class InMemorySpanExporterProducer { + @Produces + @Singleton + InMemorySpanExporter inMemorySpanExporter() { + return InMemorySpanExporter.create(); + } + } +} diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/ReactiveBookResource.java b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/ReactiveBookResource.java new file mode 100644 index 00000000000000..12c80231853357 --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/java/io/quarkus/it/opentelemetry/ReactiveBookResource.java @@ -0,0 +1,56 @@ +package io.quarkus.it.opentelemetry; + +import static com.mongodb.client.model.Filters.eq; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Response; + +import org.bson.Document; + +import io.quarkus.mongodb.reactive.ReactiveMongoClient; +import io.quarkus.mongodb.reactive.ReactiveMongoCollection; + +@Path("/reactive-books") +public class ReactiveBookResource { + + @Inject + ReactiveMongoClient client; + + private ReactiveMongoCollection getCollection() { + return client.getDatabase("books").getCollection("my-reactive-collection", Book.class); + } + + @DELETE + public CompletableFuture clearCollection() { + return getCollection() + .deleteMany(new Document()) + .onItem().transform(x -> Response.ok().build()) + .subscribeAsCompletionStage(); + } + + @GET + public CompletionStage> getBooks() { + return getCollection().find().collect().asList().subscribeAsCompletionStage(); + } + + @POST + public CompletionStage addBook(Book book) { + return getCollection().insertOne(book) + .onItem().transform(x -> Response.accepted().build()) + .subscribeAsCompletionStage(); + } + + @GET + @Path("/{author}") + public CompletionStage> getBooksByAuthor(@PathParam("author") String author) { + return getCollection().find(eq("author", author)) + .collect().asList() + .subscribeAsCompletionStage(); + } + +} diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/resources/META-INF/resources/test.html b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/resources/META-INF/resources/test.html new file mode 100644 index 00000000000000..d3e7968fdf060a --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/resources/META-INF/resources/test.html @@ -0,0 +1 @@ +Test diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/resources/application.properties b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/resources/application.properties new file mode 100644 index 00000000000000..e08de52ee52759 --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# Setting these for tests explicitly. Not required in normal application +quarkus.application.name=opentelemetry-mongodb-client-instrumentation-it +quarkus.application.version=999-SNAPSHOT + +quarkus.datasource.devservices.enabled=false +quarkus.devservices.enabled=false + +quarkus.mongodb.connection-string=mongodb://localhost:27017 + +# speed up build +quarkus.otel.bsp.schedule.delay=100 +quarkus.otel.bsp.export.timeout=5s +quarkus.mongodb.tracing.enabled=true diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/test/java/io/quarkus/it/opentelemetry/BookResourceIT.java b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/test/java/io/quarkus/it/opentelemetry/BookResourceIT.java new file mode 100644 index 00000000000000..f539786475e123 --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/test/java/io/quarkus/it/opentelemetry/BookResourceIT.java @@ -0,0 +1,7 @@ +package io.quarkus.it.opentelemetry; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class BookResourceIT extends BookResourceTest { +} diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/src/test/java/io/quarkus/it/opentelemetry/BookResourceTest.java b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/test/java/io/quarkus/it/opentelemetry/BookResourceTest.java new file mode 100644 index 00000000000000..7f07b6a7bb1d50 --- /dev/null +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/src/test/java/io/quarkus/it/opentelemetry/BookResourceTest.java @@ -0,0 +1,106 @@ +package io.quarkus.it.opentelemetry; + +import static io.restassured.RestAssured.get; +import static io.restassured.RestAssured.given; +import static java.net.HttpURLConnection.HTTP_OK; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.common.ResourceArg; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.mongodb.MongoTestResource; +import io.restassured.common.mapper.TypeRef; + +@QuarkusTest +@QuarkusTestResource(value = MongoTestResource.class, initArgs = @ResourceArg(name = MongoTestResource.VERSION, value = "V6_0")) +class BookResourceTest { + + @BeforeEach + @AfterEach + void reset() { + given().get("/reset").then().statusCode(HTTP_OK); + await().atMost(Duration.ofSeconds(30L)).until(() -> { + // make sure spans are cleared + List> spans = getSpans(); + if (!spans.isEmpty()) { + given().get("/reset").then().statusCode(HTTP_OK); + } + return spans.isEmpty(); + }); + } + + @Test + void blockingClient() { + testInsertBooks("/books"); + assertTraceAvailable("my-collection"); + } + + @Test + void reactiveClient() { + testInsertBooks("/reactive-books"); + assertTraceAvailable("my-reactive-collection"); + } + + private void assertTraceAvailable(String dbCollectionName) { + await().atMost(Duration.ofSeconds(30L)).untilAsserted(() -> { + boolean traceAvailable = false; + for (Map spanData : getSpans()) { + if (spanData.get("attributes") instanceof Map attr) { + var cmd = (String) attr.get("mongodb.command"); + if (cmd != null) { + assertThat(cmd).contains(dbCollectionName, "books"); + traceAvailable = true; + } + } + } + assertThat(traceAvailable).as("Mongodb statement was not traced.").isTrue(); + }); + } + + static void testInsertBooks(String endpoint) { + given() + .delete(endpoint) + .then() + .assertThat() + .statusCode(200); + + List initialBooks = get(endpoint).as(new TypeRef<>() { + }); + assertThat(initialBooks).as(initialBooks::toString).isEmpty(); + + saveBook(new Book("Victor Hugo", "Les Misérables"), endpoint); + saveBook(new Book("Victor Hugo", "Notre-Dame de Paris"), endpoint); + await().atMost(Duration.ofSeconds(60L)) + .untilAsserted(() -> assertThat(get(endpoint).as(List.class)).hasSize(2)); + + saveBook(new Book("Charles Baudelaire", "Les fleurs du mal"), endpoint); + + assertThat(get(endpoint).as(List.class)).hasSize(3); + + List books = get("%s/Victor Hugo".formatted(endpoint)).as(new TypeRef<>() { + }); + assertThat(books).hasSize(2); + } + + private static void saveBook(Book book, String endpoint) { + given() + .header("Content-Type", "application/json") + .body(book) + .post(endpoint) + .then().assertThat().statusCode(202); + } + + private List> getSpans() { + return get("/export").body().as(new TypeRef<>() { + }); + } +}