diff --git a/driver-core/build.gradle.kts b/driver-core/build.gradle.kts index 4f06805a6e..7e260b18d2 100644 --- a/driver-core/build.gradle.kts +++ b/driver-core/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { optionalImplementation(libs.snappy.java) optionalImplementation(libs.zstd.jni) + optionalImplementation(libs.micrometer) testImplementation(project(path = ":bson", configuration = "testArtifacts")) testImplementation(libs.reflections) diff --git a/driver-core/src/main/com/mongodb/MongoClientSettings.java b/driver-core/src/main/com/mongodb/MongoClientSettings.java index 31206e5602..fa5d989073 100644 --- a/driver-core/src/main/com/mongodb/MongoClientSettings.java +++ b/driver-core/src/main/com/mongodb/MongoClientSettings.java @@ -30,9 +30,11 @@ import com.mongodb.connection.SslSettings; import com.mongodb.connection.TransportSettings; import com.mongodb.event.CommandListener; +import com.mongodb.internal.tracing.TracingManager; import com.mongodb.lang.Nullable; import com.mongodb.spi.dns.DnsClient; import com.mongodb.spi.dns.InetAddressResolver; +import com.mongodb.internal.tracing.Tracer; import org.bson.UuidRepresentation; import org.bson.codecs.BsonCodecProvider; import org.bson.codecs.BsonValueCodecProvider; @@ -118,6 +120,7 @@ public final class MongoClientSettings { private final InetAddressResolver inetAddressResolver; @Nullable private final Long timeoutMS; + private final TracingManager tracingManager; /** * Gets the default codec registry. It includes the following providers: @@ -238,6 +241,7 @@ public static final class Builder { private ContextProvider contextProvider; private DnsClient dnsClient; private InetAddressResolver inetAddressResolver; + private TracingManager tracingManager; private Builder() { } @@ -275,6 +279,7 @@ private Builder(final MongoClientSettings settings) { if (settings.heartbeatSocketTimeoutSetExplicitly) { heartbeatSocketTimeoutMS = settings.heartbeatSocketSettings.getReadTimeout(MILLISECONDS); } + tracingManager = settings.tracingManager; } /** @@ -723,6 +728,20 @@ Builder heartbeatSocketTimeoutMS(final int heartbeatSocketTimeoutMS) { return this; } + /** + * Sets the tracer to use for creating Spans for operations and commands. + * + * @param tracer the tracer + * @see com.mongodb.tracing.MicrometerTracer + * @return this + * @since 5.5 + */ + @Alpha(Reason.CLIENT) + public Builder tracer(final Tracer tracer) { + this.tracingManager = new TracingManager(tracer); + return this; + } + /** * Build an instance of {@code MongoClientSettings}. * @@ -1040,6 +1059,17 @@ public ContextProvider getContextProvider() { return contextProvider; } + /** + * Get the tracer to create Spans for operations and commands. + * + * @return this + * @since 5.5 + */ + @Alpha(Reason.CLIENT) + public TracingManager getTracingManager() { + return tracingManager; + } + @Override public boolean equals(final Object o) { if (this == o) { @@ -1156,5 +1186,6 @@ private MongoClientSettings(final Builder builder) { heartbeatConnectTimeoutSetExplicitly = builder.heartbeatConnectTimeoutMS != 0; contextProvider = builder.contextProvider; timeoutMS = builder.timeoutMS; + tracingManager = (builder.tracingManager == null) ? TracingManager.NO_OP : builder.tracingManager; } } diff --git a/driver-core/src/main/com/mongodb/MongoNamespace.java b/driver-core/src/main/com/mongodb/MongoNamespace.java index 67991c2a95..ad721b7371 100644 --- a/driver-core/src/main/com/mongodb/MongoNamespace.java +++ b/driver-core/src/main/com/mongodb/MongoNamespace.java @@ -91,7 +91,7 @@ public static void checkCollectionNameValidity(final String collectionName) { public MongoNamespace(final String fullName) { notNull("fullName", fullName); this.fullName = fullName; - this.databaseName = getDatatabaseNameFromFullName(fullName); + this.databaseName = getDatabaseNameFromFullName(fullName); this.collectionName = getCollectionNameFullName(fullName); checkDatabaseNameValidity(databaseName); checkCollectionNameValidity(collectionName); @@ -190,7 +190,7 @@ private static String getCollectionNameFullName(final String namespace) { return namespace.substring(firstDot + 1); } - private static String getDatatabaseNameFromFullName(final String namespace) { + private static String getDatabaseNameFromFullName(final String namespace) { int firstDot = namespace.indexOf('.'); if (firstDot == -1) { return ""; diff --git a/driver-core/src/main/com/mongodb/internal/connection/CommandMessage.java b/driver-core/src/main/com/mongodb/internal/connection/CommandMessage.java index 12543e92cc..0660938e4f 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/CommandMessage.java +++ b/driver-core/src/main/com/mongodb/internal/connection/CommandMessage.java @@ -186,6 +186,10 @@ BsonDocument getCommandDocument(final ByteBufferBsonOutput bsonOutput) { } } + BsonDocument getCommand() { + return command; + } + /** * Get the field name from a buffer positioned at the start of the document sequence identifier of an OP_MSG Section of type * `PAYLOAD_TYPE_1_DOCUMENT_SEQUENCE`. diff --git a/driver-core/src/main/com/mongodb/internal/connection/InternalStreamConnection.java b/driver-core/src/main/com/mongodb/internal/connection/InternalStreamConnection.java index bf009aa1b0..4567ee9792 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/InternalStreamConnection.java +++ b/driver-core/src/main/com/mongodb/internal/connection/InternalStreamConnection.java @@ -51,6 +51,9 @@ import com.mongodb.internal.logging.StructuredLogger; import com.mongodb.internal.session.SessionContext; import com.mongodb.internal.time.Timeout; +import com.mongodb.internal.tracing.Span; +import com.mongodb.internal.tracing.TraceContext; +import com.mongodb.internal.tracing.TracingManager; import com.mongodb.lang.Nullable; import org.bson.BsonBinaryReader; import org.bson.BsonDocument; @@ -94,6 +97,19 @@ import static com.mongodb.internal.connection.ProtocolHelper.isCommandOk; import static com.mongodb.internal.logging.LogMessage.Level.DEBUG; import static com.mongodb.internal.thread.InterruptionUtil.translateInterruptedException; +import static com.mongodb.internal.tracing.Tags.CLIENT_CONNECTION_ID; +import static com.mongodb.internal.tracing.Tags.CURSOR_ID; +import static com.mongodb.internal.tracing.Tags.NAMESPACE; +import static com.mongodb.internal.tracing.Tags.QUERY_OPCODE; +import static com.mongodb.internal.tracing.Tags.QUERY_SUMMARY; +import static com.mongodb.internal.tracing.Tags.QUERY_TEXT; +import static com.mongodb.internal.tracing.Tags.SERVER_ADDRESS; +import static com.mongodb.internal.tracing.Tags.SERVER_CONNECTION_ID; +import static com.mongodb.internal.tracing.Tags.SERVER_PORT; +import static com.mongodb.internal.tracing.Tags.SERVER_TYPE; +import static com.mongodb.internal.tracing.Tags.SESSION_ID; +import static com.mongodb.internal.tracing.Tags.SYSTEM; +import static com.mongodb.internal.tracing.Tags.TRANSACTION_NUMBER; import static java.util.Arrays.asList; /** @@ -376,7 +392,7 @@ public T sendAndReceive(final CommandMessage message, final Decoder decod message, decoder, operationContext); try { return sendAndReceiveInternal.get(); - } catch (MongoCommandException e) { + } catch (Throwable e) { if (reauthenticationIsTriggered(e)) { return reauthenticateAndRetry(sendAndReceiveInternal, operationContext); } @@ -391,9 +407,8 @@ public void sendAndReceiveAsync(final CommandMessage message, final Decoder< AsyncSupplier sendAndReceiveAsyncInternal = c -> sendAndReceiveAsyncInternal( message, decoder, operationContext, c); - beginAsync().thenSupply(c -> { - sendAndReceiveAsyncInternal.getAsync(c); - }).onErrorIf(e -> reauthenticationIsTriggered(e), (t, c) -> { + + beginAsync().thenSupply(sendAndReceiveAsyncInternal::getAsync).onErrorIf(this::reauthenticationIsTriggered, (t, c) -> { reauthenticateAndRetryAsync(sendAndReceiveAsyncInternal, operationContext, c); }).finish(callback); } @@ -432,15 +447,31 @@ public boolean reauthenticationIsTriggered(@Nullable final Throwable t) { private T sendAndReceiveInternal(final CommandMessage message, final Decoder decoder, final OperationContext operationContext) { CommandEventSender commandEventSender; + Span tracingSpan = createTracingSpan(message, operationContext); + try (ByteBufferBsonOutput bsonOutput = new ByteBufferBsonOutput(this)) { message.encode(bsonOutput, operationContext); - commandEventSender = createCommandEventSender(message, bsonOutput, operationContext); + BsonDocument commandDocument = message.getCommandDocument(bsonOutput); + + commandEventSender = createCommandEventSender(message, commandDocument, operationContext); commandEventSender.sendStartedEvent(); + + if (tracingSpan != null && operationContext.getTracingManager().isCommandPayloadEnabled()) { + tracingSpan.tag(QUERY_TEXT, commandDocument.toJson()); + } + try { sendCommandMessage(message, bsonOutput, operationContext); } catch (Exception e) { + if (tracingSpan != null) { + tracingSpan.error(e); + } commandEventSender.sendFailedEvent(e); throw e; + } finally { + if (tracingSpan != null) { + tracingSpan.end(); + } } } @@ -553,7 +584,8 @@ private void sendAndReceiveAsyncInternal(final CommandMessage message, final try { message.encode(bsonOutput, operationContext); - CommandEventSender commandEventSender = createCommandEventSender(message, bsonOutput, operationContext); + BsonDocument commandDocument = message.getCommandDocument(bsonOutput); + CommandEventSender commandEventSender = createCommandEventSender(message, commandDocument, operationContext); commandEventSender.sendStartedEvent(); Compressor localSendCompressor = sendCompressor; if (localSendCompressor == null || SECURITY_SENSITIVE_COMMANDS.contains(message.getCommandDocument(bsonOutput).getFirstKey())) { @@ -952,7 +984,7 @@ public void onResult(@Nullable final ByteBuf result, @Nullable final Throwable t private static final StructuredLogger COMMAND_PROTOCOL_LOGGER = new StructuredLogger("protocol.command"); - private CommandEventSender createCommandEventSender(final CommandMessage message, final ByteBufferBsonOutput bsonOutput, + private CommandEventSender createCommandEventSender(final CommandMessage message, final BsonDocument commandDocument, final OperationContext operationContext) { boolean listensOrLogs = commandListener != null || COMMAND_PROTOCOL_LOGGER.isRequired(DEBUG, getClusterId()); if (!recordEverything && (isMonitoringConnection || !opened() || !authenticated.get() || !listensOrLogs)) { @@ -960,11 +992,80 @@ private CommandEventSender createCommandEventSender(final CommandMessage message } return new LoggingCommandEventSender( SECURITY_SENSITIVE_COMMANDS, SECURITY_SENSITIVE_HELLO_COMMANDS, description, commandListener, - operationContext, message, bsonOutput, + operationContext, message, commandDocument, COMMAND_PROTOCOL_LOGGER, loggerSettings); } private ClusterId getClusterId() { return description.getConnectionId().getServerId().getClusterId(); } + + /** + * Creates a tracing span for the given command message. + *

+ * The span is only created if tracing is enabled and the command is not security-sensitive. + * It attaches various tags to the span, such as database system, namespace, query summary, opcode, + * server address, port, server type, client and server connection IDs, and, if applicable, + * transaction number and session ID. For cursor fetching commands, the parent context is retrieved using the cursor ID. + * If command payload tracing is enabled, the command document is also attached as a tag. + * + * @param message the command message to trace + * @param operationContext the operation context containing tracing and session information + * @return the created {@link Span}, or {@code null} if tracing is not enabled or the command is security-sensitive + */ + @Nullable + private Span createTracingSpan(final CommandMessage message, final OperationContext operationContext) { + TracingManager tracingManager = operationContext.getTracingManager(); + BsonDocument command = message.getCommand(); + String commandName = command.getFirstKey(); + if (!tracingManager.isEnabled() + || SECURITY_SENSITIVE_COMMANDS.contains(commandName) + || SECURITY_SENSITIVE_HELLO_COMMANDS.contains(commandName)) { + return null; + } + + // Retrieving the appropriate parent context for the span. + TraceContext parentContext; + long cursorId = -1; + if (command.containsKey("getMore")) { + cursorId = command.getInt64("getMore").longValue(); + parentContext = tracingManager.getCursorParentContext(cursorId); + } else { + parentContext = tracingManager.getParentContext(operationContext.getId()); + } + + Span span = tracingManager + .addSpan("Command " + commandName, parentContext) + .tag(SYSTEM, "mongodb") + .tag(NAMESPACE, message.getNamespace().getDatabaseName()) + .tag(QUERY_SUMMARY, command.toString()) + .tag(QUERY_OPCODE, String.valueOf(message.getOpCode())); + + if (cursorId != -1) { + span.tag(CURSOR_ID, cursorId); + } + + tagServerAndConnectionInfo(span, message); + tagSessionAndTransactionInfo(span, operationContext); + + return span; + } + + private void tagServerAndConnectionInfo(final Span span, final CommandMessage message) { + span.tag(SERVER_ADDRESS, serverId.getAddress().getHost()) + .tag(SERVER_PORT, String.valueOf(serverId.getAddress().getPort())) + .tag(SERVER_TYPE, message.getSettings().getServerType().name()) + .tag(CLIENT_CONNECTION_ID, this.description.getConnectionId().toString()) + .tag(SERVER_CONNECTION_ID, String.valueOf(this.description.getConnectionId().getServerValue())); + } + + private void tagSessionAndTransactionInfo(final Span span, final OperationContext operationContext) { + SessionContext sessionContext = operationContext.getSessionContext(); + if (sessionContext.hasSession() && !sessionContext.isImplicitSession()) { + span.tag(TRANSACTION_NUMBER, String.valueOf(sessionContext.getTransactionNumber())) + .tag(SESSION_ID, String.valueOf(sessionContext.getSessionId() + .get(sessionContext.getSessionId().getFirstKey()) + .asBinary().asUuid())); + } + } } diff --git a/driver-core/src/main/com/mongodb/internal/connection/LoggingCommandEventSender.java b/driver-core/src/main/com/mongodb/internal/connection/LoggingCommandEventSender.java index 3821ca947c..136ec10a1d 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/LoggingCommandEventSender.java +++ b/driver-core/src/main/com/mongodb/internal/connection/LoggingCommandEventSender.java @@ -78,7 +78,7 @@ class LoggingCommandEventSender implements CommandEventSender { @Nullable final CommandListener commandListener, final OperationContext operationContext, final CommandMessage message, - final ByteBufferBsonOutput bsonOutput, + final BsonDocument commandDocument, final StructuredLogger logger, final LoggerSettings loggerSettings) { this.description = description; @@ -88,7 +88,7 @@ class LoggingCommandEventSender implements CommandEventSender { this.loggerSettings = loggerSettings; this.startTimeNanos = System.nanoTime(); this.message = message; - this.commandDocument = message.getCommandDocument(bsonOutput); + this.commandDocument = commandDocument; this.commandName = commandDocument.getFirstKey(); this.redactionRequired = securitySensitiveCommands.contains(commandName) || (securitySensitiveHelloCommands.contains(commandName) && commandDocument.containsKey("speculativeAuthenticate")); diff --git a/driver-core/src/main/com/mongodb/internal/connection/OperationContext.java b/driver-core/src/main/com/mongodb/internal/connection/OperationContext.java index 7e0de92da1..ab200d025f 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/OperationContext.java +++ b/driver-core/src/main/com/mongodb/internal/connection/OperationContext.java @@ -27,6 +27,7 @@ import com.mongodb.internal.TimeoutSettings; import com.mongodb.internal.VisibleForTesting; import com.mongodb.internal.session.SessionContext; +import com.mongodb.internal.tracing.TracingManager; import com.mongodb.lang.Nullable; import com.mongodb.selector.ServerSelector; @@ -51,15 +52,17 @@ public class OperationContext { private final ServerApi serverApi; @Nullable private final String operationName; + private final TracingManager tracingManager; public OperationContext(final RequestContext requestContext, final SessionContext sessionContext, final TimeoutContext timeoutContext, @Nullable final ServerApi serverApi) { - this(requestContext, sessionContext, timeoutContext, serverApi, null); + this(requestContext, sessionContext, timeoutContext, serverApi, null, TracingManager.NO_OP); } public OperationContext(final RequestContext requestContext, final SessionContext sessionContext, final TimeoutContext timeoutContext, - @Nullable final ServerApi serverApi, @Nullable final String operationName) { - this(NEXT_ID.incrementAndGet(), requestContext, sessionContext, timeoutContext, new ServerDeprioritization(), serverApi, operationName); + @Nullable final ServerApi serverApi, @Nullable final String operationName, final TracingManager tracingManager) { + this(NEXT_ID.incrementAndGet(), requestContext, sessionContext, timeoutContext, new ServerDeprioritization(), serverApi, + operationName, tracingManager); } public static OperationContext simpleOperationContext( @@ -69,7 +72,8 @@ public static OperationContext simpleOperationContext( NoOpSessionContext.INSTANCE, new TimeoutContext(timeoutSettings), serverApi, - null); + null, + TracingManager.NO_OP); } public static OperationContext simpleOperationContext(final TimeoutContext timeoutContext) { @@ -78,25 +82,30 @@ public static OperationContext simpleOperationContext(final TimeoutContext timeo NoOpSessionContext.INSTANCE, timeoutContext, null, - null); + null, + TracingManager.NO_OP); } public OperationContext withSessionContext(final SessionContext sessionContext) { - return new OperationContext(id, requestContext, sessionContext, timeoutContext, serverDeprioritization, serverApi, operationName); + return new OperationContext(id, requestContext, sessionContext, timeoutContext, serverDeprioritization, serverApi, operationName, tracingManager); } public OperationContext withTimeoutContext(final TimeoutContext timeoutContext) { - return new OperationContext(id, requestContext, sessionContext, timeoutContext, serverDeprioritization, serverApi, operationName); + return new OperationContext(id, requestContext, sessionContext, timeoutContext, serverDeprioritization, serverApi, operationName, tracingManager); } public OperationContext withOperationName(final String operationName) { - return new OperationContext(id, requestContext, sessionContext, timeoutContext, serverDeprioritization, serverApi, operationName); + return new OperationContext(id, requestContext, sessionContext, timeoutContext, serverDeprioritization, serverApi, operationName, tracingManager); } public long getId() { return id; } + public TracingManager getTracingManager() { + return tracingManager; + } + public SessionContext getSessionContext() { return sessionContext; } @@ -126,7 +135,8 @@ public OperationContext(final long id, final TimeoutContext timeoutContext, final ServerDeprioritization serverDeprioritization, @Nullable final ServerApi serverApi, - @Nullable final String operationName) { + @Nullable final String operationName, + final TracingManager tracingManager) { this.id = id; this.serverDeprioritization = serverDeprioritization; this.requestContext = requestContext; @@ -134,6 +144,7 @@ public OperationContext(final long id, this.timeoutContext = timeoutContext; this.serverApi = serverApi; this.operationName = operationName; + this.tracingManager = tracingManager; } @VisibleForTesting(otherwise = VisibleForTesting.AccessModifier.PRIVATE) @@ -142,7 +153,8 @@ public OperationContext(final long id, final SessionContext sessionContext, final TimeoutContext timeoutContext, @Nullable final ServerApi serverApi, - @Nullable final String operationName) { + @Nullable final String operationName, + final TracingManager tracingManager) { this.id = id; this.serverDeprioritization = new ServerDeprioritization(); this.requestContext = requestContext; @@ -150,6 +162,7 @@ public OperationContext(final long id, this.timeoutContext = timeoutContext; this.serverApi = serverApi; this.operationName = operationName; + this.tracingManager = tracingManager; } diff --git a/driver-core/src/main/com/mongodb/internal/operation/CommandBatchCursor.java b/driver-core/src/main/com/mongodb/internal/operation/CommandBatchCursor.java index d201976e5e..a061abafbe 100644 --- a/driver-core/src/main/com/mongodb/internal/operation/CommandBatchCursor.java +++ b/driver-core/src/main/com/mongodb/internal/operation/CommandBatchCursor.java @@ -75,6 +75,7 @@ class CommandBatchCursor implements AggregateResponseBatchCursor { @Nullable private List nextBatch; private boolean resetTimeoutWhenClosing; + private final long cursorId; CommandBatchCursor( final TimeoutMode timeoutMode, @@ -95,10 +96,13 @@ class CommandBatchCursor implements AggregateResponseBatchCursor { operationContext = connectionSource.getOperationContext(); this.timeoutMode = timeoutMode; + ServerCursor serverCursor = commandCursorResult.getServerCursor(); + this.cursorId = serverCursor != null ? serverCursor.getId() : -1; + operationContext.getTimeoutContext().setMaxTimeOverride(maxTimeMS); Connection connectionToPin = connectionSource.getServerDescription().getType() == ServerType.LOAD_BALANCER ? connection : null; - resourceManager = new ResourceManager(namespace, connectionSource, connectionToPin, commandCursorResult.getServerCursor()); + resourceManager = new ResourceManager(namespace, connectionSource, connectionToPin, serverCursor); resetTimeoutWhenClosing = true; } @@ -169,6 +173,7 @@ public void remove() { @Override public void close() { + operationContext.getTracingManager().removeCursorParentContext(cursorId); resourceManager.close(); } diff --git a/driver-core/src/main/com/mongodb/internal/operation/FindOperation.java b/driver-core/src/main/com/mongodb/internal/operation/FindOperation.java index ab37613db1..cabc13ce00 100644 --- a/driver-core/src/main/com/mongodb/internal/operation/FindOperation.java +++ b/driver-core/src/main/com/mongodb/internal/operation/FindOperation.java @@ -30,6 +30,7 @@ import com.mongodb.internal.binding.AsyncReadBinding; import com.mongodb.internal.binding.ReadBinding; import com.mongodb.internal.connection.OperationContext; +import com.mongodb.internal.tracing.TracingManager; import com.mongodb.lang.Nullable; import org.bson.BsonBoolean; import org.bson.BsonDocument; @@ -61,6 +62,7 @@ import static com.mongodb.internal.operation.SyncOperationHelper.createReadCommandAndExecute; import static com.mongodb.internal.operation.SyncOperationHelper.decorateReadWithRetries; import static com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection; +import static com.mongodb.internal.tracing.TracingManager.runWithTracing; /** * An operation that queries a collection using the provided criteria. @@ -296,13 +298,14 @@ public BatchCursor execute(final ReadBinding binding) { if (invalidTimeoutModeException != null) { throw invalidTimeoutModeException; } + OperationContext operationContext = binding.getOperationContext(); - RetryState retryState = initialRetryState(retryReads, binding.getOperationContext().getTimeoutContext()); - Supplier> read = decorateReadWithRetries(retryState, binding.getOperationContext(), () -> + RetryState retryState = initialRetryState(retryReads, operationContext.getTimeoutContext()); + Supplier> read = decorateReadWithRetries(retryState, operationContext, () -> withSourceAndConnection(binding::getReadConnectionSource, false, (source, connection) -> { - retryState.breakAndThrowIfRetryAnd(() -> !canRetryRead(source.getServerDescription(), binding.getOperationContext())); + retryState.breakAndThrowIfRetryAnd(() -> !canRetryRead(source.getServerDescription(), operationContext)); try { - return createReadCommandAndExecute(retryState, binding.getOperationContext(), source, namespace.getDatabaseName(), + return createReadCommandAndExecute(retryState, operationContext, source, namespace.getDatabaseName(), getCommandCreator(), CommandResultDocumentCodec.create(decoder, FIRST_BATCH), transformer(), connection); } catch (MongoCommandException e) { @@ -310,7 +313,7 @@ public BatchCursor execute(final ReadBinding binding) { } }) ); - return read.get(); + return runWithTracing(read, operationContext, "find", namespace); } @Override @@ -473,9 +476,15 @@ private TimeoutMode getTimeoutMode() { } private CommandReadTransformer> transformer() { - return (result, source, connection) -> - new CommandBatchCursor<>(getTimeoutMode(), result, batchSize, getMaxTimeForCursor(source.getOperationContext()), decoder, - comment, source, connection); + return (result, source, connection) -> { + OperationContext operationContext = source.getOperationContext(); + + // register cursor id with the operation context, so 'getMore' commands can be folded under the 'find' operation + TracingManager.linkCursorWithOperation(result, operationContext); + + return new CommandBatchCursor<>(getTimeoutMode(), result, batchSize, getMaxTimeForCursor(operationContext), decoder, + comment, source, connection); + }; } private CommandReadTransformerAsync> asyncTransformer() { diff --git a/driver-core/src/main/com/mongodb/internal/operation/MixedBulkWriteOperation.java b/driver-core/src/main/com/mongodb/internal/operation/MixedBulkWriteOperation.java index 9bc947f045..31dd2234c3 100644 --- a/driver-core/src/main/com/mongodb/internal/operation/MixedBulkWriteOperation.java +++ b/driver-core/src/main/com/mongodb/internal/operation/MixedBulkWriteOperation.java @@ -72,6 +72,7 @@ import static com.mongodb.internal.operation.OperationHelper.validateWriteRequests; import static com.mongodb.internal.operation.OperationHelper.validateWriteRequestsAndCompleteIfInvalid; import static com.mongodb.internal.operation.SyncOperationHelper.withSourceAndConnection; +import static com.mongodb.internal.tracing.TracingManager.runWithTracing; /** * An operation to execute a series of write operations in bulk. @@ -187,6 +188,7 @@ public String getCommandName() { @Override public BulkWriteResult execute(final WriteBinding binding) { TimeoutContext timeoutContext = binding.getOperationContext().getTimeoutContext(); + OperationContext operationContext = binding.getOperationContext(); /* We cannot use the tracking of attempts built in the `RetryState` class because conceptually we have to maintain multiple attempt * counters while executing a single bulk write operation: * - a counter that limits attempts to select server and checkout a connection before we created a batch; @@ -196,12 +198,12 @@ public BulkWriteResult execute(final WriteBinding binding) { * and the code related to the attempt tracking in `BulkWriteTracker` will be removed. */ RetryState retryState = new RetryState(timeoutContext); BulkWriteTracker.attachNew(retryState, retryWrites, timeoutContext); - Supplier retryingBulkWrite = decorateWriteWithRetries(retryState, binding.getOperationContext(), () -> + Supplier retryingBulkWrite = decorateWriteWithRetries(retryState, operationContext, () -> withSourceAndConnection(binding::getWriteConnectionSource, true, (source, connection) -> { ConnectionDescription connectionDescription = connection.getDescription(); // attach `maxWireVersion` ASAP because it is used to check whether we can retry retryState.attach(AttachmentKeys.maxWireVersion(), connectionDescription.getMaxWireVersion(), true); - SessionContext sessionContext = binding.getOperationContext().getSessionContext(); + SessionContext sessionContext = operationContext.getSessionContext(); WriteConcern writeConcern = validateAndGetEffectiveWriteConcern(this.writeConcern, sessionContext); if (!isRetryableWrite(retryWrites, writeConcern, connectionDescription, sessionContext)) { handleMongoWriteConcernWithResponseException(retryState, true, timeoutContext); @@ -210,13 +212,13 @@ public BulkWriteResult execute(final WriteBinding binding) { if (!retryState.attachment(AttachmentKeys.bulkWriteTracker()).orElseThrow(Assertions::fail).batch().isPresent()) { BulkWriteTracker.attachNew(retryState, BulkWriteBatch.createBulkWriteBatch(namespace, connectionDescription, ordered, writeConcern, - bypassDocumentValidation, retryWrites, writeRequests, binding.getOperationContext(), comment, variables), timeoutContext); + bypassDocumentValidation, retryWrites, writeRequests, operationContext, comment, variables), timeoutContext); } return executeBulkWriteBatch(retryState, writeConcern, binding, connection); }) ); try { - return retryingBulkWrite.get(); + return runWithTracing(retryingBulkWrite, operationContext, "MixedBulkWriteOperation", namespace); } catch (MongoException e) { throw transformWriteException(e); } diff --git a/driver-core/src/main/com/mongodb/internal/operation/TransactionOperation.java b/driver-core/src/main/com/mongodb/internal/operation/TransactionOperation.java index e344cfb2b6..7186c63384 100644 --- a/driver-core/src/main/com/mongodb/internal/operation/TransactionOperation.java +++ b/driver-core/src/main/com/mongodb/internal/operation/TransactionOperation.java @@ -36,6 +36,7 @@ import static com.mongodb.internal.operation.OperationHelper.LOGGER; import static com.mongodb.internal.operation.SyncOperationHelper.executeRetryableWrite; import static com.mongodb.internal.operation.SyncOperationHelper.writeConcernErrorTransformer; +import static com.mongodb.internal.tracing.TracingManager.runWithTracing; /** * A base class for transaction-related operations @@ -57,9 +58,10 @@ public WriteConcern getWriteConcern() { public Void execute(final WriteBinding binding) { isTrue("in transaction", binding.getOperationContext().getSessionContext().hasActiveTransaction()); TimeoutContext timeoutContext = binding.getOperationContext().getTimeoutContext(); - return executeRetryableWrite(binding, "admin", null, NoOpFieldNameValidator.INSTANCE, - new BsonDocumentCodec(), getCommandCreator(), - writeConcernErrorTransformer(timeoutContext), getRetryCommandModifier(timeoutContext)); + // Add a span for 'commit' or 'abort' operation if tracing is enabled + return runWithTracing(() -> executeRetryableWrite(binding, "admin", null, NoOpFieldNameValidator.INSTANCE, + new BsonDocumentCodec(), getCommandCreator(), + writeConcernErrorTransformer(timeoutContext), getRetryCommandModifier(timeoutContext)), binding.getOperationContext(), getCommandName(), null); } @Override diff --git a/driver-core/src/main/com/mongodb/internal/tracing/Span.java b/driver-core/src/main/com/mongodb/internal/tracing/Span.java new file mode 100644 index 0000000000..649a907740 --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/tracing/Span.java @@ -0,0 +1,117 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.internal.tracing; + + +/** + * Represents a tracing span for the driver internal operations. + *

+ * A span records information about a single operation, such as tags, events, errors, and its context. + * Implementations can be used to propagate tracing information and record telemetry. + *

+ *

+ * Spans can be used to trace different aspects of MongoDB driver activity: + *

    + *
  • Command Spans: Trace the execution of MongoDB commands (e.g., find, insert, update).
  • + *
  • Operation Spans: Trace higher-level operations, which may include multiple commands or internal steps.
  • + *
  • Transaction Spans: Trace the lifecycle of a transaction, including all operations and commands within it.
  • + *
+ *

+ * + * @since 5.6 + */ +public interface Span { + /** + * A no-op implementation of the Span interface. + *

+ * This implementation is used as a default when no actual tracing is required. + * All methods in this implementation perform no operations and return default values. + *

+ */ + Span EMPTY = new Span() { + @Override + public Span tag(final String key, final String value) { + return this; + } + + @Override + public Span tag(final String key, final Long value) { + return this; + } + + @Override + public void event(final String event) { + } + + @Override + public void error(final Throwable throwable) { + } + + @Override + public void end() { + } + + @Override + public TraceContext context() { + return TraceContext.EMPTY; + } + }; + + /** + * Adds a tag to the span with a key-value pair. + * + * @param key The tag key. + * @param value The tag value. + * @return The current instance of the span. + */ + Span tag(String key, String value); + + /** + * Adds a tag to the span with a key and a numeric value. + * + * @param key The tag key. + * @param value The numeric tag value. + * @return The current instance of the span. + */ + Span tag(String key, Long value); + + /** + * Records an event in the span. + * + * @param event The event description. + */ + void event(String event); + + /** + * Records an error for this span. + * + * @param throwable The error to record. + */ + void error(Throwable throwable); + + /** + * Ends the span, marking it as complete. + */ + void end(); + + /** + * Retrieves the context associated with the span. + * + * @return The trace context associated with the span. + */ + TraceContext context(); +} diff --git a/driver-core/src/main/com/mongodb/internal/tracing/Tags.java b/driver-core/src/main/com/mongodb/internal/tracing/Tags.java new file mode 100644 index 0000000000..2ac9212ceb --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/tracing/Tags.java @@ -0,0 +1,43 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.internal.tracing; + +/** + * Contains constant tag names used for tracing and monitoring MongoDB operations. + * These tags are typically used to annotate spans or events with relevant metadata. + * + * @since 5.6 + */ +public final class Tags { + private Tags() { + } + + public static final String SYSTEM = "db.system"; + public static final String NAMESPACE = "db.namespace"; + public static final String COLLECTION = "db.collection.name"; + public static final String QUERY_SUMMARY = "db.query.summary"; + public static final String QUERY_OPCODE = "db.query.opcode"; + public static final String QUERY_TEXT = "db.query.text"; + public static final String CURSOR_ID = "db.mongodb.cursor_id"; + public static final String SERVER_ADDRESS = "server.address"; + public static final String SERVER_PORT = "server.port"; + public static final String SERVER_TYPE = "server.type"; + public static final String CLIENT_CONNECTION_ID = "db.mongodb.client_connection_id"; + public static final String SERVER_CONNECTION_ID = "db.mongodb.server_connection_id"; + public static final String TRANSACTION_NUMBER = "db.mongodb.txnNumber"; + public static final String SESSION_ID = "db.mongodb.lsid"; +} diff --git a/driver-core/src/main/com/mongodb/internal/tracing/TraceContext.java b/driver-core/src/main/com/mongodb/internal/tracing/TraceContext.java new file mode 100644 index 0000000000..cb2f6ef102 --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/tracing/TraceContext.java @@ -0,0 +1,23 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.internal.tracing; + +@SuppressWarnings("InterfaceIsType") +public interface TraceContext { + TraceContext EMPTY = new TraceContext() { + }; +} diff --git a/driver-core/src/main/com/mongodb/internal/tracing/Tracer.java b/driver-core/src/main/com/mongodb/internal/tracing/Tracer.java new file mode 100644 index 0000000000..c2881e9e2f --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/tracing/Tracer.java @@ -0,0 +1,97 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.internal.tracing; + +import com.mongodb.lang.Nullable; + +/** + * A Tracer interface that provides methods for tracing commands, operations and transactions. + *

+ * This interface defines methods to retrieve the current trace context, create new spans, and check if tracing is enabled. + * It also includes a no-operation (NO_OP) implementation for cases where tracing is not required. + *

+ * + * @since 5.6 + */ +public interface Tracer { + Tracer NO_OP = new Tracer() { + + @Override + public TraceContext currentContext() { + return TraceContext.EMPTY; + } + + @Override + public Span nextSpan(final String name) { + return Span.EMPTY; + } + + @Override + public Span nextSpan(final String name, @Nullable final TraceContext parent) { + return Span.EMPTY; + } + + @Override + public boolean enabled() { + return false; + } + + @Override + public boolean includeCommandPayload() { + return false; + } + }; + + /** + * Retrieves the current trace context from the Micrometer tracer. + * + * @return A {@link TraceContext} representing the underlying {@link io.micrometer.tracing.TraceContext}. + * exists. + */ + TraceContext currentContext(); + + /** + * Creates a new span with the specified name. + * + * @param name The name of the span. + * @return A {@link Span} representing the newly created span. + */ + Span nextSpan(String name); // uses current active span + + /** + * Creates a new span with the specified name and optional parent trace context. + * + * @param name The name of the span. + * @param parent The parent {@link TraceContext}, or null if no parent context is provided. + * @return A {@link Span} representing the newly created span. + */ + Span nextSpan(String name, @Nullable TraceContext parent); // manually attach the next span to the provided parent + + /** + * Indicates whether tracing is enabled. + * + * @return {@code true} if tracing is enabled, {@code false} otherwise. + */ + boolean enabled(); + + /** + * Indicates whether command payloads are included in the trace context. + * + * @return {@code true} if command payloads are allowed, {@code false} otherwise. + */ + boolean includeCommandPayload(); // whether the tracer allows command payloads in the trace context +} diff --git a/driver-core/src/main/com/mongodb/internal/tracing/TracingManager.java b/driver-core/src/main/com/mongodb/internal/tracing/TracingManager.java new file mode 100644 index 0000000000..e560a6ed82 --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/tracing/TracingManager.java @@ -0,0 +1,314 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.internal.tracing; + +import com.mongodb.MongoNamespace; +import com.mongodb.internal.connection.OperationContext; +import com.mongodb.lang.Nullable; +import com.mongodb.session.ServerSession; +import org.bson.BsonDocument; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import static com.mongodb.internal.tracing.Tags.COLLECTION; +import static com.mongodb.internal.tracing.Tags.CURSOR_ID; +import static com.mongodb.internal.tracing.Tags.NAMESPACE; +import static com.mongodb.internal.tracing.Tags.SYSTEM; + +/** + * Manages tracing spans for MongoDB driver activities. + *

+ * This class provides methods to create and manage spans for commands, operations and transactions. + * It integrates with a {@link Tracer} to propagate tracing information and record telemetry. + *

+ */ +public class TracingManager { + /** + * A no-op instance of the TracingManager used when tracing is disabled. + */ + public static final TracingManager NO_OP = new TracingManager(Tracer.NO_OP); + + private final Tracer tracer; + private final TraceContext parentContext; + private final Map transactions = new ConcurrentHashMap<>(); + private final Map cursors = new ConcurrentHashMap<>(); + private final Map operations = new ConcurrentHashMap<>(); + + /** + * Constructs a new TracingManager with the specified tracer. + * + * @param tracer The tracer to use for tracing operations. + */ + public TracingManager(final Tracer tracer) { + this(tracer, tracer.currentContext()); + } + + /** + * Constructs a new TracingManager with the specified tracer and parent context. + * + * @param tracer The tracer to use for tracing operations. + * @param parentContext The parent trace context. + */ + public TracingManager(final Tracer tracer, final TraceContext parentContext) { + this.tracer = tracer; + this.parentContext = parentContext; + } + + /** + * Creates a new span with the specified name and parent trace context. + *

+ * This method is used to create a span that is linked to a parent context, + * enabling hierarchical tracing of operations. + *

+ * + * @param name The name of the span. + * @param parentContext The parent trace context to associate with the span. + * @return The created span. + */ + public Span addSpan(final String name, @Nullable final TraceContext parentContext) { + return tracer.nextSpan(name, parentContext); + } + + /** + * Creates a new span for the specified operation name and ID. + * + * @param name The name of the operation. + * @param operationId The ID of the operation. + * @return The created span. + */ + public Span addSpan(final String name, final Long operationId) { + Span span = tracer.nextSpan(name); + operations.put(operationId, span); + return span; + } + + /** + * Creates a new transaction span for the specified server session. + * + * @param session The server session. + * @return The created transaction span. + */ + public Span addTransactionSpan(final ServerSession session) { + Span span = tracer.nextSpan("transaction", parentContext); + transactions.put(getTransactionId(session), span.context()); + return span; + } + + /** + * Cleans up the transaction context for the specified server session. + * + * @param serverSession The server session. + */ + public void cleanupTransactionContext(final ServerSession serverSession) { + transactions.remove(getTransactionId(serverSession)); + } + + /** + * Cleans up the operation context for the specified operation ID. + * + * @param operationId The ID of the operation. + */ + public void cleanContexts(final Long operationId) { + operations.remove(operationId); + } + + /** + * Allows for the command spans to be linked to their parent operation spans. + * + * @param operationId The ID of the operation. + * @return The parent trace context, or null if none exists. + */ + @Nullable + public TraceContext getParentContext(final Long operationId) { + if (!operations.containsKey(operationId)) { + return null; + } + return operations.get(operationId).context(); + } + + /** + * Retrieves the parent trace context for the specified cursor ID. + * + * @param cursorId The ID of the cursor. + * @return The parent trace context, or null if none exists. + */ + @Nullable + public TraceContext getCursorParentContext(final long cursorId) { + return cursors.get(cursorId); + } + + /** + * Removes the cursor's parent context for the specified cursor ID. + * + * @param cursorId The ID of the cursor. + */ + public void removeCursorParentContext(final long cursorId) { + cursors.remove(cursorId); + } + + /** + * Checks whether tracing is enabled. + * + * @return True if tracing is enabled, false otherwise. + */ + public boolean isEnabled() { + return tracer.enabled(); + } + + /** + * Executes the specified supplier with tracing enabled. + * + * @param supplier The supplier to execute. + * @param operationContext The operation context. + * @param opName The name of the operation. + * @param namespace The MongoDB namespace, or null if none exists. + * @param The type of the result. + * @return The result of the supplier. + */ + public static T runWithTracing(final Supplier supplier, final OperationContext operationContext, final String opName, + @Nullable final MongoNamespace namespace) { + TracingManager tracingManager = operationContext.getTracingManager(); + Span tracingSpan = tracingManager.addOperationSpan(buildSpanName(opName, namespace), operationContext); + addNamespaceTags(tracingSpan, namespace); + + try { + return supplier.get(); + } catch (Throwable t) { + tracingSpan.error(t); + throw t; + } finally { + tracingSpan.end(); + tracingManager.cleanContexts(operationContext.getId()); + } + } + + /** + * Links a cursor with its parent operation's context. This maintains the relationship between + * the initial operation and subsequent 'getMore' commands that fetch the cursor's content. + * + * @param queryResult The query result document. + * @param operationContext The operation context. + */ + public static void linkCursorWithOperation(final BsonDocument queryResult, final OperationContext operationContext) { + long cursorId = queryResult.getDocument("cursor").getInt64("id").longValue(); + operationContext.getTracingManager().addCursorParentContext(cursorId, operationContext.getId()); + } + + /** + * Checks whether command payload tracing is enabled. + * + * @return True if command payload tracing is enabled, false otherwise. + */ + public boolean isCommandPayloadEnabled() { + return this.tracer.includeCommandPayload(); + } + + /** + * Creates a new span for the specified operation name and context. + * If the operation is part of a transaction, the transaction's span context is used. + * + * @param name The name of the operation. + * @param operationContext The operation context. + * @return The created span. + */ + private Span addOperationSpan(final String name, final OperationContext operationContext) { + // If this is part of a transaction, get the transaction's span context + String sessionIdTransactionId = getTransactionId(operationContext); + Span span = sessionIdTransactionId != null && transactions.containsKey(sessionIdTransactionId) + ? tracer.nextSpan(name, transactions.get(sessionIdTransactionId)) + : tracer.nextSpan(name); + operations.put(operationContext.getId(), span); + return span; + } + + /** + * Links the cursor ID to its parent operation, allowing for traceability of 'getMore' commands + * + * @param cursorId The ID of the cursor. + * @param operationId The ID of the operation. + */ + private void addCursorParentContext(final long cursorId, final long operationId) { + if (!operations.containsKey(operationId)) { + throw new IllegalArgumentException("Operation ID " + operationId + " does not exist."); + } + Span operationSpan = operations.get(operationId); + operationSpan.tag(CURSOR_ID, cursorId); + cursors.put(cursorId, operationSpan.context()); + } + + /** + * Builds a span name based on the operation name and namespace. + * + * @param opName The name of the operation. + * @param namespace The MongoDB namespace, or null if none exists. + * @return The span name. + */ + private static String buildSpanName(final String opName, @Nullable final MongoNamespace namespace) { + return namespace != null ? opName + " " + namespace.getFullName() : opName; + } + + /** + * Adds namespace-related tags to the specified span. + * + * @param span The span to add tags to. + * @param namespace The MongoDB namespace, or null if none exists. + */ + private static void addNamespaceTags(final Span span, @Nullable final MongoNamespace namespace) { + span.tag(SYSTEM, "mongodb"); + if (namespace != null) { + span.tag(NAMESPACE, namespace.getDatabaseName()); + span.tag(COLLECTION, namespace.getCollectionName()); + } + } + + /** + * Retrieves the transaction ID for the specified operation context. + * The ID is constructed from the session identifier and the transaction number. + * + * @param operationContext The operation context. + * @return The transaction ID, or null if none exists. + */ + @Nullable + private String getTransactionId(final OperationContext operationContext) { + if (operationContext.getSessionContext().hasSession() + && !operationContext.getSessionContext().isImplicitSession() + && operationContext.getSessionContext().hasActiveTransaction()) { + return operationContext.getSessionContext().getSessionId() + .get(operationContext.getSessionContext().getSessionId().getFirstKey()) + .asBinary() + .asUuid() + "-" + operationContext.getSessionContext().getTransactionNumber(); + } + return null; + } + + /** + * Retrieves the transaction ID for the specified server session. + * The ID is constructed from the session identifier and the transaction number. + * + * @param session The server session. + * @return The transaction ID. + */ + private String getTransactionId(final ServerSession session) { + return session.getIdentifier() + .get(session.getIdentifier().getFirstKey()) + .asBinary() + .asUuid() + .toString() + "-" + session.getTransactionNumber(); + } +} diff --git a/driver-core/src/main/com/mongodb/internal/tracing/package-info.java b/driver-core/src/main/com/mongodb/internal/tracing/package-info.java new file mode 100644 index 0000000000..6b1f711c20 --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/tracing/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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. + */ + +/** + * Contains classes related to sessions + */ +@NonNullApi +package com.mongodb.internal.tracing; + +import com.mongodb.lang.NonNullApi; diff --git a/driver-core/src/main/com/mongodb/tracing/MicrometerTracer.java b/driver-core/src/main/com/mongodb/tracing/MicrometerTracer.java new file mode 100644 index 0000000000..ed678b93ca --- /dev/null +++ b/driver-core/src/main/com/mongodb/tracing/MicrometerTracer.java @@ -0,0 +1,164 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.tracing; + +import com.mongodb.internal.tracing.Span; +import com.mongodb.internal.tracing.TraceContext; +import com.mongodb.internal.tracing.Tracer; +import com.mongodb.lang.Nullable; + +/** + * A {@link Tracer} implementation that delegates tracing operations to a Micrometer {@link io.micrometer.tracing.Tracer}. + *

+ * This class enables integration of MongoDB driver tracing with Micrometer-based tracing systems. + * It provides methods to create and manage spans using the Micrometer tracing API. + *

+ * + * @since 5.6 + */ +public class MicrometerTracer implements Tracer { + private final io.micrometer.tracing.Tracer tracer; + private final boolean allowCommandPayload; + + /** + * Constructs a new {@link MicrometerTracer} instance. + * + * @param tracer The Micrometer {@link io.micrometer.tracing.Tracer} to delegate tracing operations to. + */ + public MicrometerTracer(final io.micrometer.tracing.Tracer tracer) { + this(tracer, false); + } + + /** + * Constructs a new {@link MicrometerTracer} instance with an option to allow command payloads. + * + * @param tracer The Micrometer {@link io.micrometer.tracing.Tracer} to delegate tracing operations to. + * @param allowCommandPayload Whether to allow command payloads in the trace context. + */ + public MicrometerTracer(final io.micrometer.tracing.Tracer tracer, final boolean allowCommandPayload) { + this.tracer = tracer; + this.allowCommandPayload = allowCommandPayload; + } + + @Override + public TraceContext currentContext() { + return new MicrometerTraceContext(tracer.currentTraceContext().context()); + } + + @Override + public Span nextSpan(final String name) { + return new MicrometerSpan(tracer.nextSpan().name(name).start()); + } + + @Override + public Span nextSpan(final String name, @Nullable final TraceContext parent) { + if (parent instanceof MicrometerTraceContext) { + io.micrometer.tracing.TraceContext micrometerContext = ((MicrometerTraceContext) parent).getTraceContext(); + if (micrometerContext != null) { + return new MicrometerSpan(tracer.spanBuilder() + .name(name) + .setParent(micrometerContext) + .start()); + } + } + return nextSpan(name); + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean includeCommandPayload() { + return allowCommandPayload; + } + + /** + * Represents a Micrometer-based trace context. + */ + private static class MicrometerTraceContext implements TraceContext { + private final io.micrometer.tracing.TraceContext traceContext; + + /** + * Constructs a new {@link MicrometerTraceContext} instance. + * + * @param traceContext The Micrometer {@link io.micrometer.tracing.TraceContext}, or null if none exists. + */ + MicrometerTraceContext(@Nullable final io.micrometer.tracing.TraceContext traceContext) { + this.traceContext = traceContext; + } + + /** + * Retrieves the underlying Micrometer trace context. + * + * @return The Micrometer {@link io.micrometer.tracing.TraceContext}, or null if none exists. + */ + @Nullable + public io.micrometer.tracing.TraceContext getTraceContext() { + return traceContext; + } + } + + /** + * Represents a Micrometer-based span. + */ + private static class MicrometerSpan implements Span { + private final io.micrometer.tracing.Span span; + + /** + * Constructs a new {@link MicrometerSpan} instance. + * + * @param span The Micrometer {@link io.micrometer.tracing.Span} to delegate operations to. + */ + MicrometerSpan(final io.micrometer.tracing.Span span) { + this.span = span; + } + + @Override + public Span tag(final String key, final String value) { + span.tag(key, value); + return this; + } + + @Override + public Span tag(final String key, final Long value) { + span.tag(key, value); + return this; + } + + @Override + public void event(final String event) { + span.event(event); + } + + @Override + public void error(final Throwable throwable) { + span.error(throwable); + } + + @Override + public void end() { + span.end(); + } + + @Override + public TraceContext context() { + return new MicrometerTraceContext(span.context()); + } + } +} diff --git a/driver-core/src/main/com/mongodb/tracing/package-info.java b/driver-core/src/main/com/mongodb/tracing/package-info.java new file mode 100644 index 0000000000..2ec7551d30 --- /dev/null +++ b/driver-core/src/main/com/mongodb/tracing/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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. + */ + +/** + * Contains classes related to sessions + */ +@NonNullApi +package com.mongodb.tracing; + +import com.mongodb.lang.NonNullApi; diff --git a/driver-core/src/test/resources/specifications b/driver-core/src/test/resources/specifications index 668992950d..97d8a5bd53 160000 --- a/driver-core/src/test/resources/specifications +++ b/driver-core/src/test/resources/specifications @@ -1 +1 @@ -Subproject commit 668992950d975d3163e538849dd20383a214fc37 +Subproject commit 97d8a5bd535ea2648be475ba375b84461f7443db diff --git a/driver-core/src/test/unit/com/mongodb/internal/connection/CommandHelperTest.java b/driver-core/src/test/unit/com/mongodb/internal/connection/CommandHelperTest.java index f7873379c3..447a1efb5d 100644 --- a/driver-core/src/test/unit/com/mongodb/internal/connection/CommandHelperTest.java +++ b/driver-core/src/test/unit/com/mongodb/internal/connection/CommandHelperTest.java @@ -118,7 +118,6 @@ void testIsCommandOk() { assertFalse(CommandHelper.isCommandOk(new BsonDocument())); } - OperationContext createOperationContext() { return new OperationContext(IgnorableRequestContext.INSTANCE, NoOpSessionContext.INSTANCE, new TimeoutContext(TimeoutSettings.DEFAULT), ServerApi.builder().version(ServerApiVersion.V1).build()); diff --git a/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/OperationExecutorImpl.java b/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/OperationExecutorImpl.java index dacf0c9b82..c649976507 100644 --- a/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/OperationExecutorImpl.java +++ b/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/OperationExecutorImpl.java @@ -205,7 +205,8 @@ private OperationContext getOperationContext(final RequestContext requestContext new ReadConcernAwareNoOpSessionContext(readConcern), createTimeoutContext(session, timeoutSettings), mongoClient.getSettings().getServerApi(), - commandName); + commandName, + mongoClient.getSettings().getTracingManager()); } private ReadPreference getReadPreferenceForBinding(final ReadPreference readPreference, @Nullable final ClientSession session) { diff --git a/driver-sync/build.gradle.kts b/driver-sync/build.gradle.kts index 95cd097997..b37d022629 100644 --- a/driver-sync/build.gradle.kts +++ b/driver-sync/build.gradle.kts @@ -38,6 +38,9 @@ dependencies { // lambda testing testImplementation(libs.aws.lambda.core) + + // Tracing + testImplementation(libs.bundles.micrometer.test) } configureMavenPublication { diff --git a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java index b60fc90316..7d2f44519f 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/ClientSessionImpl.java @@ -36,6 +36,8 @@ import com.mongodb.internal.operation.WriteOperation; import com.mongodb.internal.session.BaseClientSessionImpl; import com.mongodb.internal.session.ServerSessionPool; +import com.mongodb.internal.tracing.Span; +import com.mongodb.internal.tracing.TracingManager; import com.mongodb.lang.Nullable; import static com.mongodb.MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL; @@ -54,11 +56,16 @@ final class ClientSessionImpl extends BaseClientSessionImpl implements ClientSes private boolean messageSentInCurrentTransaction; private boolean commitInProgress; private TransactionOptions transactionOptions; + private final TracingManager tracingManager; + private Span transactionSpan; + private boolean isConvenientTransaction = false; + private Throwable lastSpanEvent = null; ClientSessionImpl(final ServerSessionPool serverSessionPool, final Object originator, final ClientSessionOptions options, - final OperationExecutor operationExecutor) { + final OperationExecutor operationExecutor, final TracingManager tracingManager) { super(serverSessionPool, originator, options); this.operationExecutor = operationExecutor; + this.tracingManager = tracingManager; } @Override @@ -141,6 +148,7 @@ public void abortTransaction() { } finally { clearTransactionContext(); cleanupTransaction(TransactionState.ABORTED); + finalizeTransactionSpan(TransactionState.ABORTED.name()); } } @@ -167,6 +175,10 @@ private void startTransaction(final TransactionOptions transactionOptions, final if (!writeConcern.isAcknowledged()) { throw new MongoClientException("Transactions do not support unacknowledged write concern"); } + + if (tracingManager.isEnabled()) { + transactionSpan = tracingManager.addTransactionSpan(getServerSession()); + } clearTransactionContext(); setTimeoutContext(timeoutContext); } @@ -187,7 +199,7 @@ private void commitTransaction(final boolean resetTimeout) { if (transactionState == TransactionState.NONE) { throw new IllegalStateException("There is no transaction started"); } - + boolean exceptionThrown = false; try { if (messageSentInCurrentTransaction) { ReadConcern readConcern = transactionOptions.getReadConcern(); @@ -206,11 +218,16 @@ private void commitTransaction(final boolean resetTimeout) { .recoveryToken(getRecoveryToken()), readConcern, this); } } catch (MongoException e) { + exceptionThrown = true; clearTransactionContextOnError(e); + handleTransactionSpanError(e); throw e; } finally { transactionState = TransactionState.COMMITTED; commitInProgress = false; + if (!exceptionThrown) { + finalizeTransactionSpan(TransactionState.COMMITTED.name()); + } } } @@ -230,49 +247,56 @@ public T withTransaction(final TransactionBody transactionBody, final Tra notNull("transactionBody", transactionBody); long startTime = ClientSessionClock.INSTANCE.now(); TimeoutContext withTransactionTimeoutContext = createTimeoutContext(options); + isConvenientTransaction = true; - outer: - while (true) { - T retVal; - try { - startTransaction(options, withTransactionTimeoutContext.copyTimeoutContext()); - retVal = transactionBody.execute(); - } catch (Throwable e) { - if (transactionState == TransactionState.IN) { - abortTransaction(); - } - if (e instanceof MongoException && !(e instanceof MongoOperationTimeoutException)) { - MongoException exceptionToHandle = OperationHelper.unwrap((MongoException) e); - if (exceptionToHandle.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL) - && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { - continue; - } - } - throw e; - } - if (transactionState == TransactionState.IN) { + try { + outer: while (true) { + T retVal; try { - commitTransaction(false); - break; - } catch (MongoException e) { - clearTransactionContextOnError(e); - if (!(e instanceof MongoOperationTimeoutException) - && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { - applyMajorityWriteConcernToTransactionOptions(); - - if (!(e instanceof MongoExecutionTimeoutException) - && e.hasErrorLabel(UNKNOWN_TRANSACTION_COMMIT_RESULT_LABEL)) { + startTransaction(options, withTransactionTimeoutContext.copyTimeoutContext()); + retVal = transactionBody.execute(); + } catch (Throwable e) { + if (transactionState == TransactionState.IN) { + abortTransaction(); + } + if (e instanceof MongoException && !(e instanceof MongoOperationTimeoutException)) { + MongoException exceptionToHandle = OperationHelper.unwrap((MongoException) e); + if (exceptionToHandle.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL) + && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { + spanFinalizing(); continue; - } else if (e.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL)) { - continue outer; } } throw e; } + if (transactionState == TransactionState.IN) { + while (true) { + try { + commitTransaction(false); + break; + } catch (MongoException e) { + clearTransactionContextOnError(e); + if (!(e instanceof MongoOperationTimeoutException) + && ClientSessionClock.INSTANCE.now() - startTime < MAX_RETRY_TIME_LIMIT_MS) { + applyMajorityWriteConcernToTransactionOptions(); + + if (!(e instanceof MongoExecutionTimeoutException) + && e.hasErrorLabel(UNKNOWN_TRANSACTION_COMMIT_RESULT_LABEL)) { + continue; + } else if (e.hasErrorLabel(TRANSIENT_TRANSACTION_ERROR_LABEL)) { + spanFinalizing(); + continue outer; + } + } + throw e; + } + } + } + return retVal; } - } - return retVal; + } finally { + spanFinalizing(); } } @@ -317,4 +341,48 @@ private TimeoutContext createTimeoutContext(final TransactionOptions transaction TransactionOptions.merge(transactionOptions, getOptions().getDefaultTransactionOptions()), operationExecutor.getTimeoutSettings())); } + + private void handleTransactionSpanError(final Throwable e) { + if (transactionSpan != null) { + if (isConvenientTransaction) { + // report error as event (since subsequent retries might succeed, also keep track of the last event + transactionSpan.event(e.toString()); + lastSpanEvent = e; + } else { + // report error as Span error + transactionSpan.error(e); + } + + if (!isConvenientTransaction) { + transactionSpan.end(); + transactionSpan = null; + } + tracingManager.cleanupTransactionContext(this.getServerSession()); + } + } + + private void finalizeTransactionSpan(final String status) { + if (transactionSpan != null) { + transactionSpan.event(status); + // clear previous commit error if any + if (!isConvenientTransaction) { + transactionSpan.end(); + } + lastSpanEvent = null; // clear previous commit error if any + + tracingManager.cleanupTransactionContext(this.getServerSession()); + } + } + + private void spanFinalizing() { + if (transactionSpan != null) { + if (lastSpanEvent != null) { + transactionSpan.error(lastSpanEvent); + } + transactionSpan.end(); + } + isConvenientTransaction = false; + transactionSpan = null; + lastSpanEvent = null; + } } diff --git a/driver-sync/src/main/com/mongodb/client/internal/MongoClientImpl.java b/driver-sync/src/main/com/mongodb/client/internal/MongoClientImpl.java index 6870277b1c..acb19fe12d 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/MongoClientImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/MongoClientImpl.java @@ -106,7 +106,8 @@ public MongoClientImpl(final Cluster cluster, operationExecutor, settings.getReadConcern(), settings.getReadPreference(), settings.getRetryReads(), settings.getRetryWrites(), settings.getServerApi(), new ServerSessionPool(cluster, TimeoutSettings.create(settings), settings.getServerApi()), - TimeoutSettings.create(settings), settings.getUuidRepresentation(), settings.getWriteConcern()); + TimeoutSettings.create(settings), settings.getUuidRepresentation(), + settings.getWriteConcern(), settings.getTracingManager()); this.closed = new AtomicBoolean(); BsonDocument clientMetadataDocument = delegate.getCluster().getClientMetadata().getBsonDocument(); diff --git a/driver-sync/src/main/com/mongodb/client/internal/MongoClusterImpl.java b/driver-sync/src/main/com/mongodb/client/internal/MongoClusterImpl.java index 0430d9407c..072de3bb97 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/MongoClusterImpl.java +++ b/driver-sync/src/main/com/mongodb/client/internal/MongoClusterImpl.java @@ -57,6 +57,7 @@ import com.mongodb.internal.operation.SyncOperations; import com.mongodb.internal.operation.WriteOperation; import com.mongodb.internal.session.ServerSessionPool; +import com.mongodb.internal.tracing.TracingManager; import com.mongodb.lang.Nullable; import org.bson.BsonDocument; import org.bson.Document; @@ -99,6 +100,7 @@ final class MongoClusterImpl implements MongoCluster { private final UuidRepresentation uuidRepresentation; private final WriteConcern writeConcern; private final SyncOperations operations; + private final TracingManager tracingManager; MongoClusterImpl( @Nullable final AutoEncryptionSettings autoEncryptionSettings, final Cluster cluster, final CodecRegistry codecRegistry, @@ -106,7 +108,8 @@ final class MongoClusterImpl implements MongoCluster { @Nullable final OperationExecutor operationExecutor, final ReadConcern readConcern, final ReadPreference readPreference, final boolean retryReads, final boolean retryWrites, @Nullable final ServerApi serverApi, final ServerSessionPool serverSessionPool, final TimeoutSettings timeoutSettings, final UuidRepresentation uuidRepresentation, - final WriteConcern writeConcern) { + final WriteConcern writeConcern, + final TracingManager tracingManager) { this.autoEncryptionSettings = autoEncryptionSettings; this.cluster = cluster; this.codecRegistry = codecRegistry; @@ -123,6 +126,8 @@ final class MongoClusterImpl implements MongoCluster { this.timeoutSettings = timeoutSettings; this.uuidRepresentation = uuidRepresentation; this.writeConcern = writeConcern; + this.tracingManager = tracingManager; + operations = new SyncOperations<>( null, BsonDocument.class, @@ -165,35 +170,35 @@ public Long getTimeout(final TimeUnit timeUnit) { public MongoCluster withCodecRegistry(final CodecRegistry codecRegistry) { return new MongoClusterImpl(autoEncryptionSettings, cluster, codecRegistry, contextProvider, crypt, originator, operationExecutor, readConcern, readPreference, retryReads, retryWrites, serverApi, serverSessionPool, timeoutSettings, - uuidRepresentation, writeConcern); + uuidRepresentation, writeConcern, tracingManager); } @Override public MongoCluster withReadPreference(final ReadPreference readPreference) { return new MongoClusterImpl(autoEncryptionSettings, cluster, codecRegistry, contextProvider, crypt, originator, operationExecutor, readConcern, readPreference, retryReads, retryWrites, serverApi, serverSessionPool, timeoutSettings, - uuidRepresentation, writeConcern); + uuidRepresentation, writeConcern, tracingManager); } @Override public MongoCluster withWriteConcern(final WriteConcern writeConcern) { return new MongoClusterImpl(autoEncryptionSettings, cluster, codecRegistry, contextProvider, crypt, originator, operationExecutor, readConcern, readPreference, retryReads, retryWrites, serverApi, serverSessionPool, timeoutSettings, - uuidRepresentation, writeConcern); + uuidRepresentation, writeConcern, tracingManager); } @Override public MongoCluster withReadConcern(final ReadConcern readConcern) { return new MongoClusterImpl(autoEncryptionSettings, cluster, codecRegistry, contextProvider, crypt, originator, operationExecutor, readConcern, readPreference, retryReads, retryWrites, serverApi, serverSessionPool, timeoutSettings, - uuidRepresentation, writeConcern); + uuidRepresentation, writeConcern, tracingManager); } @Override public MongoCluster withTimeout(final long timeout, final TimeUnit timeUnit) { return new MongoClusterImpl(autoEncryptionSettings, cluster, codecRegistry, contextProvider, crypt, originator, operationExecutor, readConcern, readPreference, retryReads, retryWrites, serverApi, serverSessionPool, - timeoutSettings.withTimeout(timeout, timeUnit), uuidRepresentation, writeConcern); + timeoutSettings.withTimeout(timeout, timeUnit), uuidRepresentation, writeConcern, tracingManager); } @Override @@ -248,7 +253,7 @@ public ClientSession startSession(final ClientSessionOptions options) { .readPreference(readPreference) .build())) .build(); - return new ClientSessionImpl(serverSessionPool, originator, mergedOptions, operationExecutor); + return new ClientSessionImpl(serverSessionPool, originator, mergedOptions, operationExecutor, tracingManager); } @Override @@ -499,7 +504,8 @@ private OperationContext getOperationContext(final ClientSession session, final new ReadConcernAwareNoOpSessionContext(readConcern), createTimeoutContext(session, executorTimeoutSettings), serverApi, - commandName); + commandName, + tracingManager); } private RequestContext getRequestContext() { diff --git a/driver-sync/src/test/functional/com/mongodb/client/TransactionProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/TransactionProseTest.java deleted file mode 100644 index 9a1426ad88..0000000000 --- a/driver-sync/src/test/functional/com/mongodb/client/TransactionProseTest.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2008-present MongoDB, Inc. - * - * 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 - * - * http://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 com.mongodb.client; - -import com.mongodb.MongoClientSettings; -import com.mongodb.MongoException; -import org.bson.Document; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import static com.mongodb.ClusterFixture.getDefaultDatabaseName; -import static com.mongodb.ClusterFixture.getMultiMongosConnectionString; -import static com.mongodb.ClusterFixture.isSharded; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; - -// See https://github.com/mongodb/specifications/blob/master/source/transactions/tests/README.md#mongos-pinning-prose-tests -public class TransactionProseTest { - private MongoClient client; - private MongoCollection collection; - - @Before - public void setUp() { - assumeTrue(canRunTests()); - MongoClientSettings.Builder builder = MongoClientSettings.builder() - .applyConnectionString(getMultiMongosConnectionString()); - - client = MongoClients.create(MongoClientSettings.builder(builder.build()) - .applyToSocketSettings(builder1 -> builder1.readTimeout(5, TimeUnit.SECONDS)) - .build()); - - collection = client.getDatabase(getDefaultDatabaseName()).getCollection(getClass().getName()); - collection.drop(); - } - - @After - public void tearDown() { - if (collection != null) { - collection.drop(); - } - if (client != null) { - client.close(); - } - } - - // Test that starting a new transaction on a pinned ClientSession unpins the session and normal - // server selection is performed for the next operation. - @Test - public void testNewTransactionUnpinsSession() throws MongoException { - ClientSession session = null; - try { - collection.insertOne(Document.parse("{}")); - session = client.startSession(); - session.startTransaction(); - collection.insertOne(session, Document.parse("{ _id : 1 }")); - session.commitTransaction(); - - Set> addresses = new HashSet<>(); - int iterations = 50; - while (iterations-- > 0) { - session.startTransaction(); - addresses.add(collection.find(session, Document.parse("{}"))); - session.commitTransaction(); - } - assertTrue(addresses.size() > 1); - } finally { - if (session != null) { - session.close(); - } - if (collection != null) { - collection.drop(); - } - } - } - - // Test non-transaction operations using a pinned ClientSession unpins the session and normal server selection is performed. - @Test - public void testNonTransactionOpsUnpinsSession() throws MongoException { - ClientSession session = null; - try { - collection.insertOne(Document.parse("{}")); - session = client.startSession(); - session.startTransaction(); - collection.insertOne(session, Document.parse("{ _id : 1 }")); - - Set> addresses = new HashSet<>(); - int iterations = 50; - while (iterations-- > 0) { - addresses.add(collection.find(session, Document.parse("{}"))); - } - assertTrue(addresses.size() > 1); - } finally { - if (session != null) { - session.close(); - } - if (collection != null) { - collection.drop(); - } - } - } - - private boolean canRunTests() { - return isSharded(); - } -} diff --git a/driver-sync/src/test/functional/com/mongodb/client/tracing/SpanTree.java b/driver-sync/src/test/functional/com/mongodb/client/tracing/SpanTree.java new file mode 100644 index 0000000000..bca04e6647 --- /dev/null +++ b/driver-sync/src/test/functional/com/mongodb/client/tracing/SpanTree.java @@ -0,0 +1,295 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.client.tracing; + +import com.mongodb.internal.tracing.Tags; +import com.mongodb.lang.Nullable; +import io.micrometer.tracing.test.simple.SimpleSpan; +import org.bson.BsonArray; +import org.bson.BsonBinary; +import org.bson.BsonDocument; +import org.bson.BsonString; +import org.bson.BsonValue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.BiConsumer; + +import static org.bson.assertions.Assertions.notNull; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Represents a tree structure of spans, where each span can have nested spans as children. + * This class provides methods to create a span tree from various sources and to validate the spans against expected values. + */ +public class SpanTree { + private final List roots = new ArrayList<>(); + + /** + * Creates a SpanTree from a BsonArray of spans. + * + * @param spans the BsonArray containing span documents + * @return a SpanTree constructed from the provided spans + */ + public static SpanTree from(final BsonArray spans) { + SpanTree spanTree = new SpanTree(); + for (final BsonValue span : spans) { + if (span.isDocument()) { + final BsonDocument spanDoc = span.asDocument(); + final String name = spanDoc.getString("name").getValue(); + final SpanNode rootNode = new SpanNode(name); + spanTree.roots.add(rootNode); + + if (spanDoc.containsKey("tags")) { + rootNode.tags = spanDoc.getDocument("tags"); + } + + if (spanDoc.containsKey("nested")) { + for (final BsonValue nestedSpan : spanDoc.getArray("nested")) { + addNestedSpans(rootNode, nestedSpan.asDocument()); + } + } + } + } + + return spanTree; + } + + /** + * Creates a SpanTree from a JSON string representation of spans. + * + * @param spansAsJson the JSON string containing span documents + * @return a SpanTree constructed from the provided JSON spans + */ + public static SpanTree from(final String spansAsJson) { + BsonArray spans = BsonArray.parse(spansAsJson); + return from(spans); + } + + /** + * Creates a SpanTree from a Deque of SimpleSpan objects. + * This method is typically used to build a tree based on the actual collected tracing spans. + * + * @param spans the Deque containing SimpleSpan objects + * @return a SpanTree constructed from the provided spans + */ + public static SpanTree from(final Deque spans) { + final SpanTree spanTree = new SpanTree(); + final Map idToSpanNode = new HashMap<>(); + for (final SimpleSpan span : spans) { + final SpanNode spanNode = new SpanNode(span.getName()); + for (final Map.Entry tag : span.getTags().entrySet()) { + // handle special case of session id (needs to be parsed into a BsonBinary) + // this is needed because the SimpleTracer reports all the collected tags as strings + if (tag.getKey().equals(Tags.SESSION_ID)) { + spanNode.tags.append(tag.getKey(), new BsonDocument().append("id", new BsonBinary(UUID.fromString(tag.getValue())))); + } else { + spanNode.tags.append(tag.getKey(), new BsonString(tag.getValue())); + } + } + idToSpanNode.put(span.context().spanId(), spanNode); + } + + for (final SimpleSpan span : spans) { + final String parentId = span.context().parentId(); + final SpanNode node = idToSpanNode.get(span.context().spanId()); + + if (!parentId.isEmpty() && idToSpanNode.containsKey(parentId)) { + idToSpanNode.get(parentId).children.add(node); + } else { // doesn't have a parent, so it is a root node + spanTree.roots.add(node); + } + } + return spanTree; + } + + /** + * Adds nested spans to the parent node based on the provided BsonDocument. + * This method recursively adds child spans to the parent span node. + * + * @param parentNode the parent span node to which nested spans will be added + * @param nestedSpan the BsonDocument representing a nested span + */ + private static void addNestedSpans(final SpanNode parentNode, final BsonDocument nestedSpan) { + final String name = nestedSpan.getString("name").getValue(); + final SpanNode childNode = new SpanNode(name, parentNode); + + if (nestedSpan.containsKey("tags")) { + childNode.tags = nestedSpan.getDocument("tags"); + } + + if (nestedSpan.containsKey("nested")) { + for (final BsonValue nested : nestedSpan.getArray("nested")) { + addNestedSpans(childNode, nested.asDocument()); + } + } + } + + /** + * Asserts that the reported spans are valid against the expected spans. + * This method checks that the reported spans match the expected spans in terms of names, tags, and structure. + * + * @param reportedSpans the SpanTree containing the reported spans + * @param expectedSpans the SpanTree containing the expected spans + * @param valueMatcher a BiConsumer to match values of tags between reported and expected spans + * @param ignoreExtraSpans if true, allows reported spans to contain extra spans not present in expected spans + */ + public static void assertValid(final SpanTree reportedSpans, final SpanTree expectedSpans, + final BiConsumer valueMatcher, + final boolean ignoreExtraSpans) { + if (ignoreExtraSpans) { + // remove from the reported spans all the nodes that are not expected + reportedSpans.roots.removeIf(node -> !expectedSpans.roots.contains(node)); + + } + + // check that we have the same root spans + if (reportedSpans.roots.size() != expectedSpans.roots.size()) { + fail("The number of reported spans does not match expected spans size. " + + "Reported: " + reportedSpans.roots.size() + + ", Expected: " + expectedSpans.roots.size() + + " ignoreExtraSpans: " + ignoreExtraSpans); + } + + for (int i = 0; i < reportedSpans.roots.size(); i++) { + assertValid(reportedSpans.roots.get(i), expectedSpans.roots.get(i), valueMatcher); + } + } + + /** + * Asserts that a reported span node is valid against an expected span node. + * This method checks that the reported span's name, tags, and children match the expected span. + * + * @param reportedNode the reported span node to validate + * @param expectedNode the expected span node to validate against + * @param valueMatcher a BiConsumer to match values of tags between reported and expected spans + */ + private static void assertValid(final SpanNode reportedNode, final SpanNode expectedNode, + final BiConsumer valueMatcher) { + // Check that the span names match + if (!reportedNode.getName().equalsIgnoreCase(expectedNode.getName())) { + fail("Reported span name " + + reportedNode.getName() + + " does not match expected span name " + + expectedNode.getName()); + } + + valueMatcher.accept(expectedNode.tags, reportedNode.tags); + + // Spans should have the same number of children + if (reportedNode.children.size() != expectedNode.children.size()) { + fail("Reported span " + reportedNode.getName() + + " has " + reportedNode.children.size() + + " children, but expected " + expectedNode.children.size()); + } + + // For every reported child span make sure it is valid against the expected child span + for (int i = 0; i < reportedNode.children.size(); i++) { + assertValid(reportedNode.children.get(i), expectedNode.children.get(i), valueMatcher); + } + } + + @Override + public String toString() { + return "SpanTree{" + + "roots=" + roots + + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final SpanTree spanTree = (SpanTree) o; + return Objects.deepEquals(roots, spanTree.roots); + } + + @Override + public int hashCode() { + return Objects.hash(roots); + } + + /** + * Represents a node in the span tree, which can have nested child spans. + * Each span node contains a name, tags, and a list of child span nodes. + */ + public static class SpanNode { + private final String name; + private BsonDocument tags = new BsonDocument(); + private final List children = new ArrayList<>(); + + public SpanNode(final String name) { + this.name = notNull("name", name); + } + + public SpanNode(final String name, @Nullable final SpanNode parent) { + this.name = notNull("name", name); + if (parent != null) { + parent.children.add(this); + } + } + + public String getName() { + return name; + } + + public List getChildren() { + return Collections.unmodifiableList(children); + } + + @Override + public String toString() { + return "SpanNode{" + + "name='" + name + '\'' + + ", tags=" + tags + + ", children=" + children + + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final SpanNode spanNode = (SpanNode) o; + return name.equalsIgnoreCase(spanNode.name) + && Objects.equals(tags, spanNode.tags) + && Objects.equals(children, spanNode.children); + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + tags.hashCode(); + result = 31 * result + children.hashCode(); + return result; + } + } +} diff --git a/driver-sync/src/test/functional/com/mongodb/client/tracing/ZipkinTracer.java b/driver-sync/src/test/functional/com/mongodb/client/tracing/ZipkinTracer.java new file mode 100644 index 0000000000..305e916d21 --- /dev/null +++ b/driver-sync/src/test/functional/com/mongodb/client/tracing/ZipkinTracer.java @@ -0,0 +1,95 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.client.tracing; + +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext; +import io.micrometer.tracing.otel.bridge.OtelTracer; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.exporter.zipkin.ZipkinSpanExporter; +import io.opentelemetry.extension.trace.propagation.B3Propagator; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.semconv.ResourceAttributes; + +/** + * A utility class to create a Zipkin tracer using OpenTelemetry protocol, useful for visualizing spans in Zipkin UI + * This tracer can be used to send spans to a Zipkin server. + *

+ * Spans are visible in the Zipkin UI at .... + *

+ * To Start Zipkin server, you can use the following command: + *

{@code
+ * docker run -d -p 9411:9411 openzipkin/zipkin
+ * }
+ */ +public final class ZipkinTracer { + private static final String ENDPOINT = "http://localhost:9411/api/v2/spans"; + + private ZipkinTracer() { + } + + /** + * Creates a Zipkin tracer with the specified service name. + * + * @param serviceName the name of the service to be used in the tracer + * @return a Tracer instance configured to send spans to Zipkin + */ + public static Tracer getTracer(final String serviceName) { + ZipkinSpanExporter zipkinExporter = ZipkinSpanExporter.builder() + .setEndpoint(ENDPOINT) + .build(); + + Resource resource = Resource.getDefault() + .merge(Resource.create( + Attributes.of( + ResourceAttributes.SERVICE_NAME, serviceName, + ResourceAttributes.SERVICE_VERSION, "1.0.0" + ) + )); + + SpanProcessor spanProcessor = SimpleSpanProcessor.create(zipkinExporter); + + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(spanProcessor) + .setResource(resource) + .build(); + + OpenTelemetry openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create( + B3Propagator.injectingSingleHeader() + )) + .build(); + + io.opentelemetry.api.trace.Tracer otelTracer = openTelemetry.getTracer("my-java-service", "1.0.0"); + + OtelCurrentTraceContext otelCurrentTraceContext = new OtelCurrentTraceContext(); + + return new OtelTracer( + otelTracer, + otelCurrentTraceContext, + null // EventPublisher can be null for basic usage + ); + } + +} diff --git a/driver-sync/src/test/functional/com/mongodb/client/unified/Entities.java b/driver-sync/src/test/functional/com/mongodb/client/unified/Entities.java index f142943169..4dfedf0a8d 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/unified/Entities.java +++ b/driver-sync/src/test/functional/com/mongodb/client/unified/Entities.java @@ -66,6 +66,9 @@ import com.mongodb.lang.NonNull; import com.mongodb.lang.Nullable; import com.mongodb.logging.TestLoggingInterceptor; +import com.mongodb.tracing.MicrometerTracer; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.test.simple.SimpleTracer; import org.bson.BsonArray; import org.bson.BsonBoolean; import org.bson.BsonDocument; @@ -112,7 +115,7 @@ public final class Entities { private static final Set SUPPORTED_CLIENT_ENTITY_OPTIONS = new HashSet<>( asList( "id", "uriOptions", "serverApi", "useMultipleMongoses", "storeEventsAsEntities", - "observeEvents", "observeLogMessages", "observeSensitiveCommands", "ignoreCommandMonitoringEvents")); + "observeEvents", "observeLogMessages", "observeSensitiveCommands", "ignoreCommandMonitoringEvents", "tracing")); private final Set entityNames = new HashSet<>(); private final Map threads = new HashMap<>(); private final Map>> tasks = new HashMap<>(); @@ -126,6 +129,7 @@ public final class Entities { private final Map clientEncryptions = new HashMap<>(); private final Map clientCommandListeners = new HashMap<>(); private final Map clientLoggingInterceptors = new HashMap<>(); + private final Map clientTracing = new HashMap<>(); private final Map clientConnectionPoolListeners = new HashMap<>(); private final Map clientServerListeners = new HashMap<>(); private final Map clientClusterListeners = new HashMap<>(); @@ -294,6 +298,10 @@ public TestLoggingInterceptor getClientLoggingInterceptor(final String id) { return getEntity(id + "-logging-interceptor", clientLoggingInterceptors, "logging interceptor"); } + public Tracer getClientTracer(final String id) { + return getEntity(id + "-tracing", clientTracing, "micrometer tracing"); + } + public TestConnectionPoolListener getConnectionPoolListener(final String id) { return getEntity(id + "-connection-pool-listener", clientConnectionPoolListeners, "connection pool listener"); } @@ -604,6 +612,22 @@ private void initClient(final BsonDocument entity, final String id, } clientSettingsBuilder.serverApi(serverApiBuilder.build()); } + + if (entity.containsKey("tracing")) { + boolean enableCommandPayload = entity.getDocument("tracing").get("enableCommandPayload", BsonBoolean.FALSE).asBoolean().getValue(); + /* To enable Zipkin backend, uncomment the following lines and ensure you have the server started + (docker run -d -p 9411:9411 openzipkin/zipkin). The tests will fail but the captured spans will be + visible in the Zipkin UI at http://localhost:9411 for debugging purpose. + * + * Tracer tracer = ZipkinTracer.getTracer("UTR"); + * putEntity(id + "-tracing", new SimpleTracer(), clientTracing); + */ + Tracer tracer = new SimpleTracer(); + putEntity(id + "-tracing", tracer, clientTracing); + + clientSettingsBuilder.tracer(new MicrometerTracer(tracer, enableCommandPayload)); + } + MongoClientSettings clientSettings = clientSettingsBuilder.build(); if (entity.containsKey("observeLogMessages")) { diff --git a/driver-sync/src/test/functional/com/mongodb/client/unified/MicrometerTracingTest.java b/driver-sync/src/test/functional/com/mongodb/client/unified/MicrometerTracingTest.java new file mode 100644 index 0000000000..84bc0734c5 --- /dev/null +++ b/driver-sync/src/test/functional/com/mongodb/client/unified/MicrometerTracingTest.java @@ -0,0 +1,27 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 + * + * http://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 com.mongodb.client.unified; + +import org.junit.jupiter.params.provider.Arguments; + +import java.util.Collection; + +final class MicrometerTracingTest extends UnifiedSyncTest { + private static Collection data() { + return getTestData("tracing/tests"); + } +} diff --git a/driver-sync/src/test/functional/com/mongodb/client/unified/UnifiedTest.java b/driver-sync/src/test/functional/com/mongodb/client/unified/UnifiedTest.java index e067e36d99..b34e9735fb 100644 --- a/driver-sync/src/test/functional/com/mongodb/client/unified/UnifiedTest.java +++ b/driver-sync/src/test/functional/com/mongodb/client/unified/UnifiedTest.java @@ -28,6 +28,7 @@ import com.mongodb.client.gridfs.GridFSBucket; import com.mongodb.client.model.Filters; import com.mongodb.client.test.CollectionHelper; +import com.mongodb.client.tracing.SpanTree; import com.mongodb.client.unified.UnifiedTestModifications.TestDef; import com.mongodb.client.vault.ClientEncryption; import com.mongodb.connection.ClusterDescription; @@ -44,6 +45,8 @@ import com.mongodb.lang.Nullable; import com.mongodb.logging.TestLoggingInterceptor; import com.mongodb.test.AfterBeforeParameterResolver; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.test.simple.SimpleTracer; import org.bson.BsonArray; import org.bson.BsonBoolean; import org.bson.BsonDocument; @@ -380,6 +383,11 @@ public void shouldPassAllOutcomes( } compareLogMessages(rootContext, definition, tweaks); } + + if (definition.containsKey("expectTracingSpans")) { + compareTracingSpans(definition); + } + } catch (TestAbortedException e) { // if a test is ignored, we do not retry throw e; @@ -487,6 +495,20 @@ private void compareLogMessages(final UnifiedTestContext rootContext, final Bson } } + private void compareTracingSpans(final BsonDocument definition) { + BsonDocument curTracingSpansForClient = definition.getDocument("expectTracingSpans"); + String clientId = curTracingSpansForClient.getString("client").getValue(); + + // Get the tracer for the client + Tracer micrometerTracer = entities.getClientTracer(clientId); + SimpleTracer simpleTracer = (SimpleTracer) micrometerTracer; + + SpanTree expectedSpans = SpanTree.from(curTracingSpansForClient.getArray("spans")); + SpanTree reportedSpans = SpanTree.from(simpleTracer.getSpans()); + boolean ignoreExtraSpans = curTracingSpansForClient.getBoolean("ignoreExtraSpans", BsonBoolean.TRUE).getValue(); + SpanTree.assertValid(reportedSpans, expectedSpans, rootContext.valueMatcher::assertValuesMatch, ignoreExtraSpans); + } + private void assertOutcome(final UnifiedTestContext context) { for (BsonValue cur : definition.getArray("outcome")) { BsonDocument curDocument = cur.asDocument(); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8b8222d66e..38805b0235 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,9 @@ reactive-streams = "1.0.4" snappy = "1.1.10.3" zstd = "1.5.5-3" jetbrains-annotations = "26.0.2" +micrometer = "1.4.5" +zipkin-reporter = "2.16.3" +opentelemetry-exporter-zipkin = "1.30.0" kotlin = "1.8.10" kotlinx-coroutines-bom = "1.6.4" @@ -93,6 +96,7 @@ reactive-streams = { module = " org.reactivestreams:reactive-streams", version.r slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } snappy-java = { module = "org.xerial.snappy:snappy-java", version.ref = "snappy" } zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd" } +micrometer = { module = "io.micrometer:micrometer-tracing", version.ref = "micrometer" } graal-sdk = { module = "org.graalvm.sdk:graal-sdk", version.ref = "graal-sdk" } graal-sdk-nativeimage = { module = "org.graalvm.sdk:nativeimage", version.ref = "graal-sdk" } @@ -172,6 +176,12 @@ project-reactor-test = { module = "io.projectreactor:reactor-test" } reactive-streams-tck = { module = " org.reactivestreams:reactive-streams-tck", version.ref = "reactive-streams" } reflections = { module = "org.reflections:reflections", version.ref = "reflections" } +micrometer-tracing-test = { module = " io.micrometer:micrometer-tracing-test", version.ref = "micrometer" } +micrometer-tracing-bridge-brave = { module = " io.micrometer:micrometer-tracing-bridge-brave", version.ref = "micrometer" } +micrometer-tracing = { module = " io.micrometer:micrometer-tracing", version.ref = "micrometer" } +micrometer-tracing-bridge-otel = { module = " io.micrometer:micrometer-tracing-bridge-otel", version.ref = "micrometer" } +zipkin-reporter = { module = " io.zipkin.reporter2:zipkin-reporter", version.ref = "zipkin-reporter" } +opentelemetry-exporter-zipkin = { module = " io.opentelemetry:opentelemetry-exporter-zipkin", version.ref = "opentelemetry-exporter-zipkin" } [bundles] aws-java-sdk-v1 = ["aws-java-sdk-v1-core", "aws-java-sdk-v1-sts"] @@ -198,6 +208,9 @@ scala-test-v2-v12 = ["scala-test-flatspec-v2-v12", "scala-test-shouldmatchers-v2 scala-test-v2-v11 = ["scala-test-flatspec-v2-v11", "scala-test-shouldmatchers-v2-v11", "scala-test-mockito-v2-v11", "scala-test-junit-runner-v2-v11", "reflections"] +micrometer-test = ["micrometer-tracing-test", "micrometer-tracing-bridge-brave", "micrometer-tracing-bridge-otel", + "micrometer-tracing", "zipkin-reporter", "opentelemetry-exporter-zipkin"] + [plugins] kotlin-gradle = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } bnd = { id = "biz.aQute.bnd.builder", version.ref = "plugin-bnd" }