diff --git a/agent/src/main/java/com/appland/appmap/process/hooks/Mongo.java b/agent/src/main/java/com/appland/appmap/process/hooks/Mongo.java
new file mode 100644
index 00000000..c8fd3625
--- /dev/null
+++ b/agent/src/main/java/com/appland/appmap/process/hooks/Mongo.java
@@ -0,0 +1,767 @@
+package com.appland.appmap.process.hooks;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.tinylog.TaggedLogger;
+
+import com.appland.appmap.config.AppMapConfig;
+import com.appland.appmap.output.v1.Event;
+import com.appland.appmap.record.Recorder;
+import com.appland.appmap.transform.annotations.ArgumentArray;
+import com.appland.appmap.transform.annotations.HookClass;
+import com.appland.appmap.transform.annotations.MethodEvent;
+import com.appland.appmap.transform.annotations.Unique;
+
+/**
+ * Hooks for the MongoDB Java driver's synchronous {@code MongoCollection} API
+ * (mongodb-driver-sync 3.x, 4.x and 5.x).
+ *
+ *
+ * This is a port of the Node agent's mongo hook. Each collection operation is
+ * recorded as two events:
+ *
+ * a function call on the driver's collection class, with the parameters
+ * named the way the Node agent names them ({@code filter}, {@code update},
+ * {@code doc}, ...) rather than the driver's own parameter names, and
+ * nested under it, a {@code sql_query} event with database_type
+ * {@code mongodb} whose "sql" is the normalized statement built by
+ * {@link MongoQueryShape}, so that Mongo operations land in the same digests
+ * and diffs as SQL queries.
+ *
+ *
+ *
+ * Overloads: a leading {@code ClientSession} argument is recorded as a
+ * parameter named {@code session} and a trailing result {@code Class} as
+ * {@code resultClass}; neither takes part in the statement. The remaining
+ * arguments map onto the Node names positionally.
+ *
+ *
+ * {@code find}, {@code aggregate}, {@code watch}, {@code listIndexes} and
+ * {@code distinct} return lazy iterables; their statement is recorded when the
+ * iterable is created, so options applied to the iterable afterwards
+ * ({@code sort}, {@code limit}, {@code projection}) are not part of it.
+ *
+ *
+ * The hook has no compile-time dependency on the driver. It reaches the
+ * collection's namespace and codec registry, and converts {@code Bson} values,
+ * write models and POJOs to documents, by reflection
+ * ({@link MongoDocumentConverter}). Any failure there degrades the shape to
+ * {@code ?}; it never fails the application's call.
+ *
+ *
+ * The {@code mongo_operation} unique key keeps a wrapper collection that
+ * delegates to the driver's implementation from recording the same operation
+ * twice. Not covered: the legacy {@code DBCollection} API, the reactive
+ * streams driver, and the Atlas Search index methods.
+ */
+@Unique("mongo_operation")
+public class Mongo {
+ private static final TaggedLogger logger = AppMapConfig.getLogger(null);
+ private static final Recorder recorder = Recorder.getInstance();
+
+ static final String MONGO_COLLECTION = "com.mongodb.client.MongoCollection";
+
+ /** Parameter names for each hooked method, in the order the Node agent uses. */
+ static final Map ARG_NAMES = new HashMap();
+ static {
+ ARG_NAMES.put("insertOne", new String[] { "doc", "options" });
+ ARG_NAMES.put("insertMany", new String[] { "docs", "options" });
+ ARG_NAMES.put("bulkWrite", new String[] { "operations", "options" });
+ ARG_NAMES.put("updateOne", new String[] { "filter", "update", "options" });
+ ARG_NAMES.put("replaceOne", new String[] { "filter", "replacement", "options" });
+ ARG_NAMES.put("updateMany", new String[] { "filter", "update", "options" });
+ ARG_NAMES.put("deleteOne", new String[] { "filter", "options" });
+ ARG_NAMES.put("deleteMany", new String[] { "filter", "options" });
+ ARG_NAMES.put("renameCollection", new String[] { "newName", "options" });
+ ARG_NAMES.put("drop", new String[] { "options" });
+ ARG_NAMES.put("find", new String[] { "filter" });
+ ARG_NAMES.put("createIndex", new String[] { "indexSpec", "options" });
+ ARG_NAMES.put("createIndexes", new String[] { "indexSpecs", "options" });
+ ARG_NAMES.put("dropIndex", new String[] { "indexName", "options" });
+ ARG_NAMES.put("dropIndexes", new String[] { "options" });
+ ARG_NAMES.put("listIndexes", new String[] { });
+ ARG_NAMES.put("estimatedDocumentCount", new String[] { "options" });
+ ARG_NAMES.put("countDocuments", new String[] { "filter", "options" });
+ ARG_NAMES.put("distinct", new String[] { "key", "filter" });
+ ARG_NAMES.put("findOneAndDelete", new String[] { "filter", "options" });
+ ARG_NAMES.put("findOneAndReplace", new String[] { "filter", "replacement", "options" });
+ ARG_NAMES.put("findOneAndUpdate", new String[] { "filter", "update", "options" });
+ ARG_NAMES.put("aggregate", new String[] { "pipeline" });
+ ARG_NAMES.put("watch", new String[] { "pipeline" });
+ }
+
+ // What was recorded for the operation in progress on this thread. The
+ // unique key above means at most one hooked operation is in progress per
+ // thread, so a single slot is enough. It prevents a return event from being
+ // emitted for a call that was never recorded (a recording that started
+ // while the operation was running), which would unbalance the call stack.
+ private enum Recorded {
+ NOTHING, CALL, CALL_AND_QUERY
+ }
+
+ private static final ThreadLocal recorded = new ThreadLocal() {
+ @Override
+ protected Recorded initialValue() {
+ return Recorded.NOTHING;
+ }
+ };
+
+ static void onCall(Event event, Object self, Object[] args, String method) {
+ recorded.set(Recorded.NOTHING);
+ if (!recorder.hasActiveSession()) {
+ return;
+ }
+
+ Operation op;
+ try {
+ op = Operation.of(self, method, args);
+ } catch (Throwable e) {
+ logger.debug(e, "failed to inspect mongo operation {}", method);
+ op = null;
+ }
+
+ event.setParameters(null);
+ if (op != null) {
+ for (int i = 0; i < op.parameterNames.length; i++) {
+ event.addParameter(args[i], op.parameterNames[i]);
+ }
+ } else if (args != null) {
+ for (int i = 0; i < args.length; i++) {
+ event.addParameter(args[i], "arg" + i);
+ }
+ }
+ event.setReceiver(self);
+ recorder.add(event);
+ recorded.set(Recorded.CALL);
+
+ if (op == null) {
+ return;
+ }
+ String statement;
+ try {
+ statement = MongoQueryShape.formatStatement(op.collection, method, op.argNames, op.operationArgs,
+ MongoDocumentConverter.forCollection(self));
+ } catch (Throwable e) {
+ logger.debug(e, "failed to format mongo statement for {}", method);
+ statement = MongoQueryShape.formatCollection(op.collection) + "." + method + "(" + MongoQueryShape.PLACEHOLDER
+ + ")";
+ }
+ Event query = Event.functionCallEvent();
+ query.setSqlQuery(MongoQueryShape.DATABASE_TYPE, statement);
+ recorder.add(query);
+ recorded.set(Recorded.CALL_AND_QUERY);
+ }
+
+ static void onReturn(Event event, Object returnValue) {
+ Recorded state = recorded.get();
+ recorded.set(Recorded.NOTHING);
+ if (state == Recorded.NOTHING) {
+ return;
+ }
+ if (state == Recorded.CALL_AND_QUERY) {
+ recorder.add(Event.functionReturnEvent());
+ }
+ event.setReturnValue(returnValue);
+ recorder.add(event);
+ }
+
+ static void onException(Event event, Throwable exception) {
+ Recorded state = recorded.get();
+ recorded.set(Recorded.NOTHING);
+ if (state == Recorded.NOTHING) {
+ return;
+ }
+ if (state == Recorded.CALL_AND_QUERY) {
+ Event queryReturn = Event.functionReturnEvent();
+ queryReturn.setException(exception);
+ recorder.add(queryReturn);
+ }
+ event.setException(exception);
+ recorder.add(event);
+ }
+
+ /** A hooked call, split into the parts the statement and the parameters need. */
+ static final class Operation {
+ final String collection;
+ /** Node-style names for every argument, session and result class included. */
+ final String[] parameterNames;
+ /** Node-style names for the arguments that take part in the statement. */
+ final String[] argNames;
+ /** The arguments that take part in the statement. */
+ final Object[] operationArgs;
+
+ private Operation(String collection, String[] parameterNames, String[] argNames, Object[] operationArgs) {
+ this.collection = collection;
+ this.parameterNames = parameterNames;
+ this.argNames = argNames;
+ this.operationArgs = operationArgs;
+ }
+
+ static Operation of(Object self, String method, Object[] args) {
+ if (args == null) {
+ args = new Object[0];
+ }
+ String[] names = ARG_NAMES.get(method);
+ if (names == null) {
+ names = new String[0];
+ }
+
+ int first = 0;
+ int last = args.length;
+ boolean session = args.length > 0 && MongoDocumentConverter.isClientSession(args[0]);
+ if (session) {
+ first = 1;
+ }
+ boolean resultClass = last > first && args[last - 1] instanceof Class;
+ if (resultClass) {
+ last--;
+ }
+
+ Object[] operationArgs = new Object[last - first];
+ System.arraycopy(args, first, operationArgs, 0, operationArgs.length);
+
+ String[] parameterNames = new String[args.length];
+ int n = 0;
+ if (session) {
+ parameterNames[n++] = "session";
+ }
+ for (int i = 0; i < operationArgs.length; i++) {
+ parameterNames[n++] = i < names.length ? names[i] : "arg" + i;
+ }
+ if (resultClass) {
+ parameterNames[n++] = "resultClass";
+ }
+
+ return new Operation(MongoDocumentConverter.collectionName(self), parameterNames, names, operationArgs);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.insertOne
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void insertOne(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "insertOne");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void insertOne(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void insertOne(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.insertMany
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void insertMany(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "insertMany");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void insertMany(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void insertMany(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.bulkWrite
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void bulkWrite(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "bulkWrite");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void bulkWrite(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void bulkWrite(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.updateOne
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void updateOne(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "updateOne");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void updateOne(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void updateOne(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.replaceOne
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void replaceOne(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "replaceOne");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void replaceOne(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void replaceOne(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.updateMany
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void updateMany(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "updateMany");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void updateMany(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void updateMany(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.deleteOne
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void deleteOne(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "deleteOne");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void deleteOne(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void deleteOne(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.deleteMany
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void deleteMany(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "deleteMany");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void deleteMany(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void deleteMany(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.renameCollection
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void renameCollection(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "renameCollection");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void renameCollection(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void renameCollection(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.drop
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void drop(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "drop");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void drop(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void drop(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.find
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void find(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "find");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void find(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void find(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.createIndex
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void createIndex(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "createIndex");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void createIndex(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void createIndex(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.createIndexes
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void createIndexes(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "createIndexes");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void createIndexes(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void createIndexes(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.dropIndex
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void dropIndex(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "dropIndex");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void dropIndex(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void dropIndex(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.dropIndexes
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void dropIndexes(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "dropIndexes");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void dropIndexes(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void dropIndexes(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.listIndexes
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void listIndexes(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "listIndexes");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void listIndexes(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void listIndexes(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.estimatedDocumentCount
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void estimatedDocumentCount(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "estimatedDocumentCount");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void estimatedDocumentCount(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void estimatedDocumentCount(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.countDocuments
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void countDocuments(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "countDocuments");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void countDocuments(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void countDocuments(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.distinct
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void distinct(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "distinct");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void distinct(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void distinct(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.findOneAndDelete
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void findOneAndDelete(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "findOneAndDelete");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void findOneAndDelete(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void findOneAndDelete(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.findOneAndReplace
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void findOneAndReplace(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "findOneAndReplace");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void findOneAndReplace(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void findOneAndReplace(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.findOneAndUpdate
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void findOneAndUpdate(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "findOneAndUpdate");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void findOneAndUpdate(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void findOneAndUpdate(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.aggregate
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void aggregate(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "aggregate");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void aggregate(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void aggregate(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+
+ // ---------------------------------------------------------------------------
+ // MongoCollection.watch
+ // ---------------------------------------------------------------------------
+
+ @ArgumentArray
+ @HookClass(MONGO_COLLECTION)
+ public static void watch(Event event, Object self, Object[] args) {
+ onCall(event, self, args, "watch");
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_RETURN)
+ public static void watch(Event event, Object self, Object returnValue, Object[] args) {
+ onReturn(event, returnValue);
+ }
+
+ @ArgumentArray
+ @HookClass(value = MONGO_COLLECTION, methodEvent = MethodEvent.METHOD_EXCEPTION)
+ public static void watch(Event event, Object self, Throwable exception, Object[] args) {
+ onException(event, exception);
+ }
+}
diff --git a/agent/src/main/java/com/appland/appmap/process/hooks/MongoDocumentConverter.java b/agent/src/main/java/com/appland/appmap/process/hooks/MongoDocumentConverter.java
new file mode 100644
index 00000000..efe2a804
--- /dev/null
+++ b/agent/src/main/java/com/appland/appmap/process/hooks/MongoDocumentConverter.java
@@ -0,0 +1,353 @@
+package com.appland.appmap.process.hooks;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.tinylog.TaggedLogger;
+
+import com.appland.appmap.config.AppMapConfig;
+
+/**
+ * Reaches into the MongoDB Java driver by reflection, so that
+ * {@link MongoQueryShape} can walk {@code Bson} filters and updates, write
+ * models, index models and POJO documents as plain Maps and Lists.
+ *
+ *
+ * Every lookup is defensive: the agent may run against any driver version
+ * (3.x through 5.x), or against a wrapper that only implements part of the
+ * API. Anything that cannot be converted is reported as null and rendered as
+ * {@code ?}.
+ */
+final class MongoDocumentConverter implements MongoQueryShape.DocumentConverter {
+ private static final TaggedLogger logger = AppMapConfig.getLogger(null);
+
+ static final String BSON = "org.bson.conversions.Bson";
+ static final String BSON_VALUE = "org.bson.BsonValue";
+ static final String BSON_DOCUMENT = "org.bson.BsonDocument";
+ static final String BSON_WRITER = "org.bson.BsonWriter";
+ static final String BSON_DOCUMENT_WRITER = "org.bson.BsonDocumentWriter";
+ static final String CODEC_REGISTRY = "org.bson.codecs.configuration.CodecRegistry";
+ static final String ENCODER = "org.bson.codecs.Encoder";
+ static final String ENCODER_CONTEXT = "org.bson.codecs.EncoderContext";
+ static final String CLIENT_SESSION = "com.mongodb.session.ClientSession";
+ static final String WRITE_MODEL = "com.mongodb.client.model.WriteModel";
+ static final String INDEX_MODEL = "com.mongodb.client.model.IndexModel";
+ static final String MONGO_NAMESPACE = "com.mongodb.MongoNamespace";
+
+ // Whether a class is (a subtype of) a named driver type. Keyed by the class,
+ // then by the type name. Classes are held weakly so unloaded applications
+ // are not pinned.
+ private static final Map, ConcurrentHashMap> typeCache = Collections
+ .synchronizedMap(new WeakHashMap, ConcurrentHashMap>());
+
+ static boolean isA(Object value, String typeName) {
+ if (value == null) {
+ return false;
+ }
+ Class> cls = value.getClass();
+ ConcurrentHashMap forClass = typeCache.get(cls);
+ if (forClass == null) {
+ forClass = new ConcurrentHashMap();
+ typeCache.put(cls, forClass);
+ }
+ Boolean known = forClass.get(typeName);
+ if (known == null) {
+ known = hasSupertype(cls, typeName);
+ forClass.put(typeName, known);
+ }
+ return known;
+ }
+
+ private static boolean hasSupertype(Class> cls, String typeName) {
+ for (Class> c = cls; c != null; c = c.getSuperclass()) {
+ if (c.getName().equals(typeName)) {
+ return true;
+ }
+ for (Class> i : c.getInterfaces()) {
+ if (i.getName().equals(typeName) || hasSupertype(i, typeName)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ static boolean isClientSession(Object value) {
+ return isA(value, CLIENT_SESSION);
+ }
+
+ // Reflection handles for one class loader's copy of the driver.
+ private static final class Handles {
+ final Class> bsonDocument;
+ final Method toBsonDocument;
+ /** The no-argument overload, available from bson 4.2 on; null before that. */
+ final Method toBsonDocumentDefault;
+ final Method registryGet;
+ final Method encode;
+ final Object encoderContext;
+ final java.lang.reflect.Constructor> documentWriter;
+
+ Handles(ClassLoader loader) throws Exception {
+ Class> bson = Class.forName(BSON, false, loader);
+ Class> registry = Class.forName(CODEC_REGISTRY, false, loader);
+ bsonDocument = Class.forName(BSON_DOCUMENT, false, loader);
+ toBsonDocument = bson.getMethod("toBsonDocument", Class.class, registry);
+ Method noArgs = null;
+ try {
+ noArgs = bson.getMethod("toBsonDocument");
+ } catch (NoSuchMethodException e) {
+ // bson < 4.2
+ }
+ toBsonDocumentDefault = noArgs;
+ registryGet = registry.getMethod("get", Class.class);
+ Class> writer = Class.forName(BSON_WRITER, false, loader);
+ Class> context = Class.forName(ENCODER_CONTEXT, false, loader);
+ encode = Class.forName(ENCODER, false, loader).getMethod("encode", writer, Object.class, context);
+ Object builder = context.getMethod("builder").invoke(null);
+ encoderContext = builder.getClass().getMethod("build").invoke(builder);
+ documentWriter = Class.forName(BSON_DOCUMENT_WRITER, false, loader).getConstructor(bsonDocument);
+ }
+ }
+
+ private static final Map handlesByLoader = Collections
+ .synchronizedMap(new WeakHashMap());
+ private static final Handles NO_HANDLES = null;
+
+ private static Handles handles(ClassLoader loader) {
+ if (loader == null) {
+ loader = ClassLoader.getSystemClassLoader();
+ }
+ synchronized (handlesByLoader) {
+ if (handlesByLoader.containsKey(loader)) {
+ return handlesByLoader.get(loader);
+ }
+ Handles h;
+ try {
+ h = new Handles(loader);
+ } catch (Throwable e) {
+ logger.debug(e, "bson classes not available from {}", loader);
+ h = NO_HANDLES;
+ }
+ handlesByLoader.put(loader, h);
+ return h;
+ }
+ }
+
+ // Accessors on the collection object itself, cached per collection class.
+ private static final Map, Method[]> collectionAccessors = Collections
+ .synchronizedMap(new WeakHashMap, Method[]>());
+
+ private static Method[] accessors(Object collection) {
+ Class> cls = collection.getClass();
+ Method[] found = collectionAccessors.get(cls);
+ if (found == null) {
+ found = new Method[2];
+ try {
+ found[0] = cls.getMethod("getNamespace");
+ found[0].setAccessible(true);
+ } catch (Throwable e) {
+ logger.debug(e, "{} has no getNamespace()", cls.getName());
+ }
+ try {
+ found[1] = cls.getMethod("getCodecRegistry");
+ found[1].setAccessible(true);
+ } catch (Throwable e) {
+ logger.debug(e, "{} has no getCodecRegistry()", cls.getName());
+ }
+ collectionAccessors.put(cls, found);
+ }
+ return found;
+ }
+
+ /** The collection name, or null when it cannot be determined. */
+ static String collectionName(Object collection) {
+ if (collection == null) {
+ return null;
+ }
+ Method getNamespace = accessors(collection)[0];
+ if (getNamespace == null) {
+ return null;
+ }
+ try {
+ Object namespace = getNamespace.invoke(collection);
+ if (namespace == null) {
+ return null;
+ }
+ Object name = namespace.getClass().getMethod("getCollectionName").invoke(namespace);
+ return name == null ? null : name.toString();
+ } catch (Throwable e) {
+ logger.debug(e, "failed to get collection name");
+ return null;
+ }
+ }
+
+ static MongoQueryShape.DocumentConverter forCollection(Object collection) {
+ Object registry = null;
+ ClassLoader loader = collection == null ? null : collection.getClass().getClassLoader();
+ if (collection != null) {
+ Method getCodecRegistry = accessors(collection)[1];
+ if (getCodecRegistry != null) {
+ try {
+ registry = getCodecRegistry.invoke(collection);
+ } catch (Throwable e) {
+ logger.debug(e, "failed to get codec registry");
+ }
+ }
+ }
+ return new MongoDocumentConverter(loader, registry);
+ }
+
+ private final Handles handles;
+ private final Object registry;
+
+ MongoDocumentConverter(ClassLoader loader, Object registry) {
+ this.handles = handles(loader);
+ this.registry = registry;
+ }
+
+ @Override
+ public Object convert(Object value, boolean documentPosition) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ if (isA(value, BSON)) {
+ return toBsonDocument(value);
+ }
+ if (isA(value, BSON_VALUE)) {
+ // A scalar BsonValue (BsonDocument and BsonArray are a Map and a List,
+ // and never get here).
+ return null;
+ }
+ if (isA(value, WRITE_MODEL)) {
+ return writeModel(value);
+ }
+ if (isA(value, INDEX_MODEL)) {
+ Map shape = new LinkedHashMap();
+ shape.put("key", getter(value, "getKeys"));
+ return shape;
+ }
+ if (isA(value, MONGO_NAMESPACE)) {
+ Object fullName = getter(value, "getFullName");
+ return fullName == null ? null : fullName.toString();
+ }
+ if (documentPosition && !isJdkType(value)) {
+ return encode(value);
+ }
+ } catch (Throwable e) {
+ logger.debug(e, "failed to convert {} to a document", value.getClass().getName());
+ }
+ return null;
+ }
+
+ private static boolean isJdkType(Object value) {
+ String name = value.getClass().getName();
+ return name.startsWith("java.") || name.startsWith("javax.");
+ }
+
+ private Object toBsonDocument(Object bson) throws Exception {
+ if (handles == null) {
+ return null;
+ }
+ if (registry != null) {
+ return handles.toBsonDocument.invoke(bson, handles.bsonDocument, registry);
+ }
+ if (handles.toBsonDocumentDefault != null) {
+ return handles.toBsonDocumentDefault.invoke(bson);
+ }
+ return null;
+ }
+
+ /**
+ * Encodes an arbitrary document object (a POJO, a Kotlin data class, ...) with
+ * the collection's codec registry, the way the driver will when it sends it.
+ * Returns null when the registry has no codec for it.
+ */
+ private Object encode(Object document) throws Exception {
+ if (handles == null || registry == null) {
+ return null;
+ }
+ Object codec;
+ try {
+ codec = handles.registryGet.invoke(registry, document.getClass());
+ } catch (Throwable e) {
+ // CodecConfigurationException: no codec for this class
+ return null;
+ }
+ if (codec == null) {
+ return null;
+ }
+ Object target = handles.bsonDocument.getConstructor().newInstance();
+ Object writer = handles.documentWriter.newInstance(target);
+ handles.encode.invoke(codec, writer, document, handles.encoderContext);
+ return target;
+ }
+
+ // Renders a WriteModel the way the Node driver's bulkWrite operations look:
+ // { "insertOne": { "document": ... } }, { "updateOne": { "filter": ...,
+ // "update": ... } }, and so on.
+ private Object writeModel(Object model) {
+ String kind = model.getClass().getSimpleName();
+ Map body = new LinkedHashMap();
+ String key;
+ switch (kind) {
+ case "InsertOneModel":
+ key = "insertOne";
+ body.put("document", document(getter(model, "getDocument")));
+ break;
+ case "UpdateOneModel":
+ case "UpdateManyModel":
+ key = kind.equals("UpdateOneModel") ? "updateOne" : "updateMany";
+ body.put("filter", getter(model, "getFilter"));
+ Object update = getter(model, "getUpdate");
+ if (update == null) {
+ update = getter(model, "getUpdatePipeline");
+ }
+ body.put("update", update);
+ break;
+ case "ReplaceOneModel":
+ key = "replaceOne";
+ body.put("filter", getter(model, "getFilter"));
+ body.put("replacement", document(getter(model, "getReplacement")));
+ break;
+ case "DeleteOneModel":
+ case "DeleteManyModel":
+ key = kind.equals("DeleteOneModel") ? "deleteOne" : "deleteMany";
+ body.put("filter", getter(model, "getFilter"));
+ break;
+ default:
+ return null;
+ }
+ Map shape = new LinkedHashMap();
+ shape.put(key, body);
+ return shape;
+ }
+
+ // A document nested in a write model sits in a document position, but the
+ // shape renderer only knows that for top-level arguments. Convert it here.
+ private Object document(Object value) {
+ if (value == null || value instanceof Map || MongoQueryShape.isSequence(value)) {
+ return value;
+ }
+ Object converted = convert(value, true);
+ return converted == null ? value : converted;
+ }
+
+ private static Object getter(Object target, String name) {
+ if (target == null) {
+ return null;
+ }
+ try {
+ Method m = target.getClass().getMethod(name);
+ m.setAccessible(true);
+ return m.invoke(target);
+ } catch (Throwable e) {
+ logger.debug(e, "{}.{}() failed", target.getClass().getName(), name);
+ return null;
+ }
+ }
+}
diff --git a/agent/src/main/java/com/appland/appmap/process/hooks/MongoQueryShape.java b/agent/src/main/java/com/appland/appmap/process/hooks/MongoQueryShape.java
new file mode 100644
index 00000000..2816fb81
--- /dev/null
+++ b/agent/src/main/java/com/appland/appmap/process/hooks/MongoQueryShape.java
@@ -0,0 +1,345 @@
+package com.appland.appmap.process.hooks;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Renders a MongoDB collection operation as a query statement with a
+ * normalized argument shape, so that it can be recorded as a {@code sql_query}
+ * event (database_type "mongodb") and compared across recordings the way SQL
+ * is.
+ *
+ *
+ * The same rules are implemented by the Node agent
+ * ({@code src/hooks/mongoQuery.ts} in appmap-node). Keep them in sync.
+ *
+ *
+ * Statement form: {@code db..(, , ...)}
+ *
+ * The collection is written {@code db.name} when the name is a plain
+ * identifier path, and {@code db.getCollection("name")} otherwise.
+ * Arguments are rendered in the order of the driver method's parameters.
+ * Trailing null arguments are omitted.
+ * Documents (filters, updates, replacements, inserted documents, index
+ * specs) keep their keys, in order, and every leaf value becomes {@code ?}.
+ * Keys are written as JSON strings.
+ * Arrays inside documents, and top-level lists of documents (insertMany,
+ * bulkWrite, createIndexes), keep only the distinct element shapes, in order
+ * of first appearance. {@code {"$in": [1, 2, 3]}} becomes
+ * {@code {"$in": [?]}}, and a thousand inserted documents of the same shape
+ * become one.
+ * Aggregation pipelines (aggregate, watch, and update pipelines) keep
+ * every stage in order, because stage order and repetition are part of the
+ * query.
+ * Name-like arguments (a distinct field, an index name, a new collection
+ * name) are kept verbatim as JSON strings; they identify the query the same
+ * way a table or column name does.
+ * Everything that is not a Map, an Iterable, an array, or convertible to
+ * a document by the {@link DocumentConverter} (BSON values such as ObjectId
+ * and Decimal128, primitives, Dates, null) is a leaf and becomes
+ * {@code ?}.
+ * Options objects ({@code UpdateOptions}, {@code IndexOptions}, ...) are
+ * opaque in Java and become {@code ?}. The Node agent renders the keys of its
+ * options document instead.
+ * Nesting deeper than {@link #MAX_DEPTH}, and cyclic references, become
+ * {@code ?}. Only the first {@link #MAX_ARRAY_ELEMENTS} elements of an array
+ * are examined.
+ *
+ */
+public final class MongoQueryShape {
+ public static final String DATABASE_TYPE = "mongodb";
+ public static final int MAX_DEPTH = 32;
+ public static final int MAX_ARRAY_ELEMENTS = 1000;
+
+ static final String PLACEHOLDER = "?";
+
+ /**
+ * Turns driver-specific objects into documents the shape renderer can walk.
+ * Implementations reach into the driver by reflection; this class has no
+ * dependency on it.
+ */
+ public interface DocumentConverter {
+ /**
+ * @param value a non-null value that is not a Map, Iterable or array
+ * @param documentPosition true when the value sits where the driver expects
+ * a document (an inserted document, a replacement, a pipeline
+ * stage), so that an arbitrary object may be encoded with the
+ * collection's codec
+ * @return a Map, Iterable or array to walk instead of {@code value}, a
+ * String to render verbatim, or null when the value is a leaf
+ */
+ Object convert(Object value, boolean documentPosition);
+ }
+
+ static final DocumentConverter NO_CONVERSION = new DocumentConverter() {
+ @Override
+ public Object convert(Object value, boolean documentPosition) {
+ return null;
+ }
+ };
+
+ enum ArgKind {
+ DOCUMENT, PIPELINE, NAME, OPTIONS
+ }
+
+ static ArgKind argKind(String name) {
+ switch (name) {
+ case "pipeline":
+ return ArgKind.PIPELINE;
+ case "key":
+ case "newName":
+ case "indexName":
+ case "indexes":
+ return ArgKind.NAME;
+ case "options":
+ return ArgKind.OPTIONS;
+ default:
+ return ArgKind.DOCUMENT;
+ }
+ }
+
+ private MongoQueryShape() {
+ }
+
+ /**
+ * Formats a collection method call as a normalized statement. Never throws: if
+ * the arguments cannot be inspected the statement is rendered with a single
+ * {@code ?} in place of the argument list.
+ */
+ public static String formatStatement(String collection, String method, String[] argNames, Object[] args,
+ DocumentConverter converter) {
+ String prefix = formatCollection(collection) + "." + method;
+ try {
+ return prefix + "(" + formatArgs(argNames, args, converter) + ")";
+ } catch (Throwable e) {
+ return prefix + "(" + PLACEHOLDER + ")";
+ }
+ }
+
+ private static final Pattern IDENTIFIER_PATH = Pattern.compile("[A-Za-z_$][\\w$]*(\\.[A-Za-z_$][\\w$]*)*");
+
+ public static String formatCollection(String name) {
+ if (name == null) {
+ return "db.getCollection(" + PLACEHOLDER + ")";
+ }
+ if (IDENTIFIER_PATH.matcher(name).matches()) {
+ return "db." + name;
+ }
+ return "db.getCollection(" + quote(name) + ")";
+ }
+
+ private static String formatArgs(String[] argNames, Object[] args, DocumentConverter converter) {
+ int last = Math.min(argNames.length, args == null ? 0 : args.length);
+ while (last > 0 && args[last - 1] == null) {
+ last--;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < last; i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append(formatArg(argNames[i], args[i], converter));
+ }
+ return sb.toString();
+ }
+
+ private static String formatArg(String name, Object value, DocumentConverter converter) {
+ switch (argKind(name)) {
+ case NAME:
+ if (value instanceof CharSequence) {
+ return quote(value.toString());
+ }
+ if (value instanceof Iterable && allStrings((Iterable>) value)) {
+ StringBuilder sb = new StringBuilder("[");
+ boolean first = true;
+ for (Object v : (Iterable>) value) {
+ if (!first) {
+ sb.append(", ");
+ }
+ first = false;
+ sb.append(quote(v.toString()));
+ }
+ return sb.append("]").toString();
+ }
+ return shape(value, false, false, converter);
+ case PIPELINE:
+ return shape(value, true, true, converter);
+ case OPTIONS:
+ return shape(value, false, false, converter);
+ case DOCUMENT:
+ default:
+ // An update can be a pipeline (a list of stages) instead of a document.
+ boolean ordered = "update".equals(name) && isSequence(value);
+ return shape(value, ordered, true, converter);
+ }
+ }
+
+ private static boolean allStrings(Iterable> values) {
+ for (Object v : values) {
+ if (!(v instanceof CharSequence)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Renders the shape of a value: keys kept, leaves replaced with {@code ?}.
+ *
+ * @param ordered when true, a top-level array keeps all of its elements in
+ * order (a pipeline); otherwise distinct element shapes are kept
+ * @param documentPosition whether the value (or the elements of a top-level
+ * array) sit where the driver expects a document
+ */
+ public static String shape(Object value, boolean ordered, boolean documentPosition, DocumentConverter converter) {
+ StringBuilder sb = new StringBuilder();
+ shapeOf(value, ordered, documentPosition, 0, new IdentityHashMap(), converter, sb);
+ return sb.toString();
+ }
+
+ private static void shapeOf(Object value, boolean ordered, boolean documentPosition, int depth,
+ IdentityHashMap ancestors, DocumentConverter converter, StringBuilder sb) {
+ if (depth > MAX_DEPTH || value == null) {
+ sb.append(PLACEHOLDER);
+ return;
+ }
+
+ if (!(value instanceof Map) && !isSequence(value)) {
+ Object converted = converter.convert(value, documentPosition);
+ if (converted == null) {
+ sb.append(PLACEHOLDER);
+ return;
+ }
+ if (converted instanceof CharSequence) {
+ sb.append(quote(converted.toString()));
+ return;
+ }
+ value = converted;
+ }
+
+ if (ancestors.containsKey(value)) {
+ sb.append(PLACEHOLDER);
+ return;
+ }
+ ancestors.put(value, Boolean.TRUE);
+ try {
+ if (value instanceof Map) {
+ shapeOfMap((Map, ?>) value, depth, ancestors, converter, sb);
+ } else if (isSequence(value)) {
+ shapeOfSequence(value, ordered, documentPosition, depth, ancestors, converter, sb);
+ } else {
+ sb.append(PLACEHOLDER);
+ }
+ } finally {
+ ancestors.remove(value);
+ }
+ }
+
+ private static void shapeOfMap(Map, ?> map, int depth, IdentityHashMap ancestors,
+ DocumentConverter converter, StringBuilder sb) {
+ sb.append('{');
+ boolean first = true;
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ first = false;
+ sb.append(quote(String.valueOf(entry.getKey()))).append(": ");
+ shapeOf(entry.getValue(), false, false, depth + 1, ancestors, converter, sb);
+ }
+ sb.append('}');
+ }
+
+ private static void shapeOfSequence(Object value, boolean ordered, boolean documentPosition, int depth,
+ IdentityHashMap ancestors, DocumentConverter converter, StringBuilder sb) {
+ List parts = new ArrayList();
+ Set seen = ordered ? null : new HashSet();
+ Iterator> it = iterator(value);
+ for (int i = 0; i < MAX_ARRAY_ELEMENTS && it.hasNext(); i++) {
+ StringBuilder part = new StringBuilder();
+ shapeOf(it.next(), false, documentPosition, depth + 1, ancestors, converter, part);
+ String rendered = part.toString();
+ if (ordered || seen.add(rendered)) {
+ parts.add(rendered);
+ }
+ }
+ sb.append('[');
+ for (int i = 0; i < parts.size(); i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append(parts.get(i));
+ }
+ sb.append(']');
+ }
+
+ static boolean isSequence(Object value) {
+ return value instanceof Iterable || (value != null && value.getClass().isArray()
+ && !value.getClass().getComponentType().isPrimitive());
+ }
+
+ private static Iterator> iterator(Object value) {
+ if (value instanceof Iterable) {
+ return ((Iterable>) value).iterator();
+ }
+ final Object[] array = (Object[]) value;
+ return new Iterator() {
+ private int i = 0;
+
+ @Override
+ public boolean hasNext() {
+ return i < array.length;
+ }
+
+ @Override
+ public Object next() {
+ return array[i++];
+ }
+ };
+ }
+
+ /** Quotes a string the way JSON.stringify does. */
+ static String quote(String s) {
+ StringBuilder sb = new StringBuilder(s.length() + 2);
+ sb.append('"');
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ case '\b':
+ sb.append("\\b");
+ break;
+ case '\f':
+ sb.append("\\f");
+ break;
+ default:
+ if (c < 0x20) {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ return sb.append('"').toString();
+ }
+}
diff --git a/agent/src/test/java/com/appland/appmap/process/hooks/MongoOperationTest.java b/agent/src/test/java/com/appland/appmap/process/hooks/MongoOperationTest.java
new file mode 100644
index 00000000..e51a323e
--- /dev/null
+++ b/agent/src/test/java/com/appland/appmap/process/hooks/MongoOperationTest.java
@@ -0,0 +1,76 @@
+package com.appland.appmap.process.hooks;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import com.appland.appmap.process.hooks.Mongo.Operation;
+
+public class MongoOperationTest {
+ /** Looks enough like a driver collection for the hook: it has a namespace. */
+ public static class FakeCollection {
+ public FakeNamespace getNamespace() {
+ return new FakeNamespace();
+ }
+ }
+
+ public static class FakeNamespace {
+ public String getCollectionName() {
+ return "people";
+ }
+ }
+
+ private static final Object SESSION = new com.mongodb.session.ClientSession() {
+ };
+
+ @Test
+ public void plainArguments() {
+ Object filter = Collections.singletonMap("a", 1);
+ Object update = Collections.singletonMap("$set", 1);
+ Operation op = Operation.of(new FakeCollection(), "updateOne", new Object[] { filter, update });
+ assertEquals("people", op.collection);
+ assertArrayEquals(new String[] { "filter", "update" }, op.parameterNames);
+ assertArrayEquals(new String[] { "filter", "update", "options" }, op.argNames);
+ assertArrayEquals(new Object[] { filter, update }, op.operationArgs);
+ }
+
+ @Test
+ public void sessionAndResultClassAreNamedButNotPartOfTheStatement() {
+ Object filter = Collections.singletonMap("a", 1);
+ Operation op = Operation.of(new FakeCollection(), "find", new Object[] { SESSION, filter, String.class });
+ assertArrayEquals(new String[] { "session", "filter", "resultClass" }, op.parameterNames);
+ assertArrayEquals(new Object[] { filter }, op.operationArgs);
+
+ op = Operation.of(new FakeCollection(), "countDocuments", new Object[] { SESSION });
+ assertArrayEquals(new String[] { "session" }, op.parameterNames);
+ assertEquals(0, op.operationArgs.length);
+
+ op = Operation.of(new FakeCollection(), "distinct", new Object[] { "email", String.class });
+ assertArrayEquals(new String[] { "key", "resultClass" }, op.parameterNames);
+ assertArrayEquals(new Object[] { "email" }, op.operationArgs);
+ }
+
+ @Test
+ public void unknownMethodsAndCollections() {
+ Operation op = Operation.of(new Object(), "somethingNew", new Object[] { 1, 2 });
+ assertNull(op.collection);
+ assertArrayEquals(new String[] { "arg0", "arg1" }, op.parameterNames);
+ assertEquals(0, op.argNames.length);
+
+ op = Operation.of(null, "find", null);
+ assertNull(op.collection);
+ assertEquals(0, op.parameterNames.length);
+ }
+
+ @Test
+ public void statementFromOperation() {
+ Object filter = Collections.singletonMap("a", 1);
+ Operation op = Operation.of(new FakeCollection(), "find", new Object[] { SESSION, filter, String.class });
+ assertEquals("db.people.find({\"a\": ?})", MongoQueryShape.formatStatement(op.collection, "find", op.argNames,
+ op.operationArgs, MongoDocumentConverter.forCollection(new FakeCollection())));
+ }
+}
diff --git a/agent/src/test/java/com/appland/appmap/process/hooks/MongoQueryShapeTest.java b/agent/src/test/java/com/appland/appmap/process/hooks/MongoQueryShapeTest.java
new file mode 100644
index 00000000..77d5aa73
--- /dev/null
+++ b/agent/src/test/java/com/appland/appmap/process/hooks/MongoQueryShapeTest.java
@@ -0,0 +1,253 @@
+package com.appland.appmap.process.hooks;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import com.appland.appmap.process.hooks.MongoQueryShape.DocumentConverter;
+
+/**
+ * The expectations here match the Node agent's tests for
+ * {@code src/hooks/mongoQuery.ts}: both agents must render the same
+ * statement for the same operation.
+ */
+public class MongoQueryShapeTest {
+ private static final DocumentConverter NONE = MongoQueryShape.NO_CONVERSION;
+
+ private static Map doc(Object... kv) {
+ Map m = new LinkedHashMap();
+ for (int i = 0; i < kv.length; i += 2) {
+ m.put((String) kv[i], kv[i + 1]);
+ }
+ return m;
+ }
+
+ private static String statement(String method, String[] names, Object... args) {
+ return MongoQueryShape.formatStatement("users", method, names, args, NONE);
+ }
+
+ private static String shape(Object value) {
+ return MongoQueryShape.shape(value, false, false, NONE);
+ }
+
+ @Test
+ public void collectionNames() {
+ assertEquals("db.users", MongoQueryShape.formatCollection("users"));
+ assertEquals("db.users.archive", MongoQueryShape.formatCollection("users.archive"));
+ assertEquals("db._tmp$1", MongoQueryShape.formatCollection("_tmp$1"));
+ assertEquals("db.getCollection(\"my-coll\")", MongoQueryShape.formatCollection("my-coll"));
+ assertEquals("db.getCollection(\"with space\")", MongoQueryShape.formatCollection("with space"));
+ assertEquals("db.getCollection(\"\")", MongoQueryShape.formatCollection(""));
+ assertEquals("db.getCollection(\"q\\\"uote\")", MongoQueryShape.formatCollection("q\"uote"));
+ assertEquals("db.getCollection(\"a..b\")", MongoQueryShape.formatCollection("a..b"));
+ assertEquals("db.getCollection(?)", MongoQueryShape.formatCollection(null));
+ }
+
+ @Test
+ public void keysKeptLeavesReplaced() {
+ assertEquals("{\"a\": ?, \"b\": ?, \"c\": ?, \"e\": ?}", shape(doc("a", 1, "b", "x", "c", null, "e", true)));
+ assertEquals("{\"z\": {\"y\": {\"x\": ?}}, \"a\": ?}", shape(doc("z", doc("y", doc("x", 1)), "a", 2)));
+ }
+
+ @Test
+ public void operatorsAreKeys() {
+ Object filter = doc("age", doc("$gt", 18, "$lt", 65), "$or", Arrays.asList(doc("a", 1), doc("b", 2)));
+ assertEquals("{\"age\": {\"$gt\": ?, \"$lt\": ?}, \"$or\": [{\"a\": ?}, {\"b\": ?}]}", shape(filter));
+ }
+
+ @Test
+ public void arraysCollapseToDistinctShapes() {
+ assertEquals("{\"$in\": [?]}", shape(doc("$in", Arrays.asList(1, 2, 3))));
+ assertEquals("[{\"a\": ?}, {\"b\": ?}]", shape(Arrays.asList(doc("a", 1), doc("a", 2), doc("b", 3), doc("a", 4))));
+ assertEquals("[?]", shape(new Object[] { "x", "y" }));
+ assertEquals("[]", shape(Collections.emptyList()));
+ assertEquals("{}", shape(Collections.emptyMap()));
+ }
+
+ @Test
+ public void orderedArraysKeepEveryElement() {
+ List pipeline = Arrays.asList(doc("$unwind", "$a"), doc("$unwind", "$b"));
+ assertEquals("[{\"$unwind\": ?}, {\"$unwind\": ?}]", MongoQueryShape.shape(pipeline, true, true, NONE));
+ // only the top level is ordered; nested arrays are still collapsed
+ Object stage = doc("$match", doc("a", doc("$in", Arrays.asList(1, 2))));
+ assertEquals("[{\"$match\": {\"a\": {\"$in\": [?]}}}]",
+ MongoQueryShape.shape(Collections.singletonList(stage), true, true, NONE));
+ }
+
+ @Test
+ public void leaves() {
+ assertEquals("{\"at\": ?, \"bytes\": ?, \"n\": ?, \"o\": ?}",
+ shape(doc("at", new Date(), "bytes", new byte[] { 1 }, "n", 1.5, "o", new Object())));
+ assertEquals("?", shape(null));
+ assertEquals("?", shape("string"));
+ }
+
+ @Test
+ public void keysAreEscaped() {
+ assertEquals("{\"he said \\\"hi\\\"\": ?, \"a.b\": ?, \"\": ?, \"\\n\": ?, \"\\u0001\": ?}",
+ shape(doc("he said \"hi\"", 1, "a.b", 2, "", 3, "\n", 4, "\u0001", 5)));
+ // non-string keys are stringified
+ Map m = new LinkedHashMap();
+ m.put(2, "x");
+ assertEquals("{\"2\": ?}", shape(m));
+ }
+
+ @Test
+ public void cyclesDoNotRecurse() {
+ Map d = doc("a", 1);
+ d.put("self", d);
+ List arr = new ArrayList();
+ arr.add(1);
+ arr.add(arr);
+ d.put("arr", arr);
+ assertEquals("{\"a\": ?, \"self\": ?, \"arr\": [?]}", shape(d));
+
+ // the same object in two places is not a cycle
+ Map inner = doc("x", 1);
+ assertEquals("{\"a\": {\"x\": ?}, \"b\": {\"x\": ?}}", shape(doc("a", inner, "b", inner)));
+ }
+
+ @Test
+ public void depthLimit() {
+ Object d = 1;
+ for (int i = 0; i < MongoQueryShape.MAX_DEPTH + 5; i++) {
+ d = doc("n", d);
+ }
+ String rendered = shape(d);
+ StringBuilder open = new StringBuilder();
+ StringBuilder close = new StringBuilder();
+ for (int i = 0; i <= MongoQueryShape.MAX_DEPTH; i++) {
+ open.append("{\"n\": ");
+ close.append("}");
+ }
+ assertTrue(rendered.startsWith(open.toString()), rendered);
+ assertTrue(rendered.endsWith("\"n\": ?" + close), rendered);
+ }
+
+ @Test
+ public void arrayElementLimit() {
+ List arr = new ArrayList();
+ for (int i = 0; i < MongoQueryShape.MAX_ARRAY_ELEMENTS; i++) {
+ arr.add(doc("a", i));
+ }
+ arr.add(doc("b", 2));
+ assertEquals("[{\"a\": ?}]", shape(arr));
+ }
+
+ @Test
+ public void statementArguments() {
+ String[] names = { "filter", "update", "options" };
+ assertEquals("db.users.updateOne({\"_id\": ?}, {\"$set\": {\"name\": ?}, \"$inc\": {\"n\": ?}}, ?)",
+ statement("updateOne", names, doc("_id", new Object()), doc("$set", doc("name", "x"), "$inc", doc("n", 1)),
+ new Object()));
+ // trailing nulls are omitted; inner nulls are placeholders
+ assertEquals("db.users.find()", statement("find", new String[] { "filter" }));
+ assertEquals("db.users.find()", statement("find", new String[] { "filter" }, (Object) null));
+ assertEquals("db.users.find({})", statement("find", new String[] { "filter", "options" }, doc(), null));
+ assertEquals("db.users.countDocuments(?, ?)",
+ statement("countDocuments", new String[] { "filter", "options" }, null, new Object()));
+ // arguments beyond the declared parameters are ignored
+ assertEquals("db.users.drop({})", statement("drop", new String[] { "options" }, doc(), "extra", 1));
+ assertEquals("db.users.listIndexes()", statement("listIndexes", new String[0]));
+ }
+
+ @Test
+ public void pipelinesKeepOrder() {
+ List pipeline = Arrays.asList(doc("$match", doc("status", "A")), doc("$unwind", "$items"),
+ doc("$unwind", "$items.parts"), doc("$group", doc("_id", "$cust", "total", doc("$sum", "$amount"))));
+ assertEquals(
+ "db.users.aggregate([{\"$match\": {\"status\": ?}}, {\"$unwind\": ?}, {\"$unwind\": ?}, {\"$group\": {\"_id\": ?, \"total\": {\"$sum\": ?}}}])",
+ statement("aggregate", new String[] { "pipeline" }, pipeline));
+ // an update given as a pipeline keeps its order too
+ assertEquals("db.users.updateMany({}, [{\"$set\": {\"a\": ?}}, {\"$set\": {\"b\": ?}}])",
+ statement("updateMany", new String[] { "filter", "update", "options" }, doc(),
+ Arrays.asList(doc("$set", doc("a", 1)), doc("$set", doc("b", 2)))));
+ }
+
+ @Test
+ public void documentListsCollapse() {
+ assertEquals("db.users.insertMany([{\"a\": ?}])",
+ statement("insertMany", new String[] { "docs", "options" }, Arrays.asList(doc("a", 1), doc("a", 2))));
+ Object ops = Arrays.asList(doc("insertOne", doc("document", doc("a", 1))),
+ doc("insertOne", doc("document", doc("a", 2))),
+ doc("updateOne", doc("filter", doc("a", 1), "update", doc("$set", doc("b", 1)))));
+ assertEquals(
+ "db.users.bulkWrite([{\"insertOne\": {\"document\": {\"a\": ?}}}, {\"updateOne\": {\"filter\": {\"a\": ?}, \"update\": {\"$set\": {\"b\": ?}}}}])",
+ statement("bulkWrite", new String[] { "operations", "options" }, ops));
+ }
+
+ @Test
+ public void namesAreKeptVerbatim() {
+ assertEquals("db.users.distinct(\"email\", {\"a\": ?})",
+ statement("distinct", new String[] { "key", "filter" }, "email", doc("a", 1)));
+ assertEquals("db.users.renameCollection(\"people\")",
+ statement("renameCollection", new String[] { "newName", "options" }, "people"));
+ assertEquals("db.users.dropIndex(\"a_1\")", statement("dropIndex", new String[] { "indexName", "options" }, "a_1"));
+ assertEquals("db.users.indexExists([\"a_1\", \"b_1\"])",
+ statement("indexExists", new String[] { "indexes", "options" }, Arrays.asList("a_1", "b_1")));
+ // a name argument that is not a string is rendered as a shape
+ assertEquals("db.users.dropIndex({\"a\": ?})",
+ statement("dropIndex", new String[] { "indexName", "options" }, doc("a", 1)));
+ assertEquals("db.users.distinct([?])", statement("distinct", new String[] { "key", "filter" }, Arrays.asList("a", 1)));
+ }
+
+ @Test
+ public void converterOutputIsWalked() {
+ DocumentConverter converter = new DocumentConverter() {
+ @Override
+ public Object convert(Object value, boolean documentPosition) {
+ if (value instanceof StringBuilder) {
+ return value.toString();
+ }
+ if (documentPosition && value instanceof Integer) {
+ return doc("boxed", value);
+ }
+ if (value instanceof Long) {
+ return doc("filter", doc("id", "x"));
+ }
+ return null;
+ }
+ };
+ assertEquals("db.users.renameCollection(\"db.people\")", MongoQueryShape.formatStatement("users",
+ "renameCollection", new String[] { "newName" }, new Object[] { new StringBuilder("db.people") }, converter));
+ // a converted value in document position, at the top level and in a list
+ assertEquals("db.users.insertOne({\"boxed\": ?})",
+ MongoQueryShape.formatStatement("users", "insertOne", new String[] { "doc" }, new Object[] { 1 }, converter));
+ assertEquals("db.users.insertMany([{\"boxed\": ?}])", MongoQueryShape.formatStatement("users", "insertMany",
+ new String[] { "docs" }, new Object[] { Arrays.asList(1, 2) }, converter));
+ // nested values are not in document position
+ assertEquals("{\"n\": ?, \"m\": {\"filter\": {\"id\": ?}}}",
+ MongoQueryShape.shape(doc("n", 1, "m", 2L), false, false, converter));
+ // options are opaque even when the converter would know them
+ assertEquals("db.users.drop(?)",
+ MongoQueryShape.formatStatement("users", "drop", new String[] { "options" }, new Object[] { 1 }, converter));
+ }
+
+ @Test
+ public void neverThrows() {
+ Map hostile = new LinkedHashMap() {
+ @Override
+ public java.util.Set> entrySet() {
+ throw new IllegalStateException("boom");
+ }
+ };
+ assertEquals("db.users.find(?)", statement("find", new String[] { "filter" }, hostile));
+ DocumentConverter failing = new DocumentConverter() {
+ @Override
+ public Object convert(Object value, boolean documentPosition) {
+ throw new RuntimeException("boom");
+ }
+ };
+ assertEquals("db.users.insertOne(?)", MongoQueryShape.formatStatement("users", "insertOne",
+ new String[] { "doc" }, new Object[] { new Object() }, failing));
+ }
+}
diff --git a/agent/src/test/java/com/mongodb/session/ClientSession.java b/agent/src/test/java/com/mongodb/session/ClientSession.java
new file mode 100644
index 00000000..2881ea2c
--- /dev/null
+++ b/agent/src/test/java/com/mongodb/session/ClientSession.java
@@ -0,0 +1,9 @@
+package com.mongodb.session;
+
+/**
+ * Stand-in for the driver's session interface, so that the Mongo hook's
+ * argument handling can be tested without the driver on the class path. The
+ * hook recognizes sessions by this type name.
+ */
+public interface ClientSession {
+}
diff --git a/agent/test/mongo/appmap.yml b/agent/test/mongo/appmap.yml
new file mode 100644
index 00000000..b186f257
--- /dev/null
+++ b/agent/test/mongo/appmap.yml
@@ -0,0 +1,3 @@
+name: mongo
+packages:
+- path: com.example.mongo
diff --git a/agent/test/mongo/build.gradle b/agent/test/mongo/build.gradle
new file mode 100644
index 00000000..63c5f04a
--- /dev/null
+++ b/agent/test/mongo/build.gradle
@@ -0,0 +1,57 @@
+buildscript {
+ ext {
+ // JDK 25 no longer compiles for a Java 8 target.
+ if (JavaVersion.current() >= JavaVersion.VERSION_25) {
+ javaVersion = JavaVersion.VERSION_17
+ } else {
+ javaVersion = JavaVersion.VERSION_1_8
+ }
+ }
+}
+
+plugins {
+ id 'java'
+}
+
+group = 'com.example'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
+}
+
+repositories {
+ mavenCentral()
+}
+
+// 4.11 is the last driver line that accepts the wire protocol version
+// mongo-java-server 1.43 speaks. The hook itself works with 3.x, 4.x and 5.x;
+// to run the tests against another driver and a real server, use
+// MONGODB_URI=mongodb://localhost:27017 ../gradlew test -PmongoDriverVersion=5.6.1
+def mongoDriverVersion = project.findProperty('mongoDriverVersion') ?: '4.11.5'
+
+dependencies {
+ implementation "org.mongodb:mongodb-driver-sync:${mongoDriverVersion}"
+ // An in-process MongoDB server, so the test needs no external service.
+ // 1.43.0 is the last release that runs on Java 8.
+ testImplementation 'de.bwaldvogel:mongo-java-server:1.43.0'
+ testImplementation platform('org.junit:junit-bom:5.8.2')
+ testImplementation 'org.junit.jupiter:junit-jupiter'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+def appmapJar = "$System.env.AGENT_JAR"
+
+test {
+ useJUnitPlatform()
+ if (System.env.AGENT_JAR) {
+ inputs.file(System.env.AGENT_JAR)
+ }
+ jvmArgs += [
+ "-javaagent:${appmapJar}",
+ "-Dappmap.config.file=appmap.yml",
+ "-Djava.util.logging.config.file=${System.env.JUL_CONFIG}",
+ // "-Dappmap.debug=true",
+ ]
+}
diff --git a/agent/test/mongo/mongo.bats b/agent/test/mongo/mongo.bats
new file mode 100644
index 00000000..5140525c
--- /dev/null
+++ b/agent/test/mongo/mongo.bats
@@ -0,0 +1,125 @@
+#!/usr/bin/env bats
+#
+# Test the MongoCollection hook against an in-process MongoDB server
+# (mongo-java-server), so no external service is needed.
+
+load '../helper'
+load '../jdbc/helper'
+
+setup_file() {
+ cd "$BATS_TEST_DIRNAME" || exit 1
+ _configure_logging
+
+ gradlew -q clean
+}
+
+setup() {
+ rm -rf tmp/appmap
+}
+
+# Loads the AppMap of one test method into $output for assert_json_eq. Note
+# that `run` overwrites $output, so call this again after a `run`.
+appmap_for() {
+ local map_file="tmp/appmap/junit/com_example_mongo_MongoCollectionTests_$1.appmap.json"
+ [ -f "$map_file" ]
+ output="$(<"$map_file")"
+}
+
+# Prints the sql of every sql_query event, one per line.
+queries() {
+ jq -r '.events[] | select(.sql_query) | .sql_query.sql' <<< "$output"
+}
+
+@test "collection operations are recorded as queries" {
+ run gradlew -q test --tests 'MongoCollectionTests.crud' --rerun-tasks
+ assert_success
+
+ appmap_for crud
+ assert_json_eq '.metadata.test_status' succeeded
+ # every query event says which database it is for
+ assert_json_eq '[.events[] | select(.sql_query) | .sql_query.database_type] | unique | .[0]' mongodb
+
+ run assert_all_calls_returned tmp/appmap/junit/*.appmap.json
+ assert_success
+
+ appmap_for crud
+ run queries
+ assert_line 'db.people.insertOne({"name": ?, "age": ?})'
+ assert_line 'db.people.insertMany([{"name": ?, "age": ?}, {"name": ?, "age": ?, "tags": [?]}])'
+ assert_line 'db.people.updateOne({"name": ?}, {"$set": {"age": ?}, "$inc": {"visits": ?}})'
+ assert_line 'db.people.updateMany({"age": {"$gt": ?}}, {"$set": {"senior": ?}})'
+ assert_line 'db.people.replaceOne({"name": ?}, {"name": ?, "age": ?})'
+ assert_line 'db.people.find({"name": ?})'
+ assert_line 'db.people.find({"$and": [{"name": {"$in": [?]}}, {"senior": {"$exists": ?}}]})'
+ assert_line 'db.people.countDocuments()'
+ assert_line 'db.people.countDocuments({"age": {"$lt": ?}})'
+ assert_line 'db.people.estimatedDocumentCount()'
+ assert_line 'db.people.distinct("name")'
+ assert_line 'db.people.aggregate([{"$match": {"age": {"$gte": ?}}}, {"$group": {"_id": ?, "total": {"$sum": ?}}}])'
+ assert_line 'db.people.deleteOne({"name": ?})'
+ assert_line 'db.people.deleteMany({"name": {"$in": [?]}})'
+}
+
+@test "the function call event carries the Node parameter names" {
+ run gradlew -q test --tests 'MongoCollectionTests.crud' --rerun-tasks
+ assert_success
+
+ appmap_for crud
+ # the query event is a child of the collection method call
+ assert_json_eq '[.events[] | select(.method_id == "updateOne")][0].parameters | map(.name) | join(",")' 'filter,update'
+ local call_id
+ call_id="$(jq -r '[.events[] | select(.method_id == "updateOne")][0].id' <<< "$output")"
+ assert_json_eq "[.events[] | select(.event == \"call\" and .id == $((call_id + 1)))][0].sql_query.database_type" mongodb
+ assert_json_eq '[.events[] | select(.method_id == "distinct")][0].parameters | map(.name) | join(",")' 'key,resultClass'
+}
+
+@test "bulk writes and update pipelines" {
+ run gradlew -q test --tests 'MongoCollectionTests.bulkWriteAndPipelineUpdate' --rerun-tasks
+ assert_success
+
+ appmap_for bulkWriteAndPipelineUpdate
+ run queries
+ assert_line 'db.people.bulkWrite([{"insertOne": {"document": {"name": ?, "age": ?}}}, {"updateOne": {"filter": {"name": ?}, "update": {"$set": {"age": ?}}}}])'
+ assert_line 'db.people.updateMany({"name": {"$exists": ?}}, [{"$set": {"adult": ?}}, {"$unset": ?}])'
+ assert_line 'db.people.findOneAndUpdate({"name": ?}, {"$set": {"age": ?}})'
+ assert_line 'db.people.findOneAndDelete({"name": ?})'
+}
+
+@test "index operations and a failed insert" {
+ run gradlew -q test --tests 'MongoCollectionTests.indexes' --rerun-tasks
+ assert_success
+
+ appmap_for indexes
+ # the duplicate insert is recorded as an exception on the query and on the call
+ assert_json_eq '[.events[] | select(.exceptions) | .exceptions[0].class] | unique | .[0]' com.mongodb.MongoWriteException
+ assert_json_eq '[.events[] | select(.exceptions)] | length' 2
+
+ run queries
+ assert_line 'db.people.createIndex({"name": ?}, ?)'
+ assert_line 'db.people.listIndexes()'
+ assert_line 'db.people.dropIndex("name_1")'
+ assert_line 'db.people.dropIndexes()'
+}
+
+@test "POJO documents" {
+ run gradlew -q test --tests 'MongoCollectionTests.pojos' --rerun-tasks
+ assert_success
+
+ appmap_for pojos
+ run queries
+ # a POJO is encoded with the collection's codec, so its fields are the shape
+ assert_line 'db.people.insertOne({"age": ?, "name": ?})'
+ assert_line 'db.people.insertMany([{"age": ?, "name": ?}])'
+ assert_line 'db.people.find({"name": ?})'
+ assert_line 'db.people.countDocuments()'
+}
+
+@test "collection names that are not identifiers" {
+ run gradlew -q test --tests 'MongoCollectionTests.oddCollectionName' --rerun-tasks
+ assert_success
+
+ appmap_for oddCollectionName
+ run queries
+ assert_line 'db.getCollection("with-dash").insertOne({"a": ?})'
+ assert_line 'db.getCollection("with-dash").countDocuments()'
+}
diff --git a/agent/test/mongo/settings.gradle b/agent/test/mongo/settings.gradle
new file mode 100644
index 00000000..7be7e14d
--- /dev/null
+++ b/agent/test/mongo/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'mongo-test'
diff --git a/agent/test/mongo/src/main/java/com/example/mongo/Person.java b/agent/test/mongo/src/main/java/com/example/mongo/Person.java
new file mode 100644
index 00000000..71d1d843
--- /dev/null
+++ b/agent/test/mongo/src/main/java/com/example/mongo/Person.java
@@ -0,0 +1,42 @@
+package com.example.mongo;
+
+import org.bson.types.ObjectId;
+
+/** A POJO stored with the driver's automatic POJO codec. */
+public class Person {
+ private ObjectId id;
+ private String name;
+ private int age;
+
+ public Person() {
+ }
+
+ public Person(String name, int age) {
+ this.name = name;
+ this.age = age;
+ }
+
+ public ObjectId getId() {
+ return id;
+ }
+
+ public void setId(ObjectId id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getAge() {
+ return age;
+ }
+
+ public void setAge(int age) {
+ this.age = age;
+ }
+}
diff --git a/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java b/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java
new file mode 100644
index 00000000..ef419a31
--- /dev/null
+++ b/agent/test/mongo/src/test/java/com/example/mongo/MongoCollectionTests.java
@@ -0,0 +1,195 @@
+package com.example.mongo;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.bson.Document;
+import org.bson.codecs.configuration.CodecRegistries;
+import org.bson.codecs.configuration.CodecRegistry;
+import org.bson.codecs.pojo.PojoCodecProvider;
+import org.bson.conversions.Bson;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+import com.mongodb.MongoClientSettings;
+import com.mongodb.MongoCommandException;
+import com.mongodb.MongoWriteException;
+import com.mongodb.client.ClientSession;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.Aggregates;
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.model.IndexOptions;
+import com.mongodb.client.model.Indexes;
+import com.mongodb.client.model.InsertOneModel;
+import com.mongodb.client.model.Sorts;
+import com.mongodb.client.model.UpdateOneModel;
+import com.mongodb.client.model.Updates;
+import com.mongodb.client.model.WriteModel;
+
+import de.bwaldvogel.mongo.MongoServer;
+import de.bwaldvogel.mongo.backend.memory.MemoryBackend;
+
+/**
+ * Exercises the driver's MongoCollection API. By default the tests run against
+ * mongo-java-server, an in-process server, so CI needs no service. Set
+ * MONGODB_URI to run them against a real MongoDB instead; the session test only
+ * runs then, because mongo-java-server does not support sessions. The bats test
+ * checks the AppMaps these tests produce.
+ */
+@Execution(ExecutionMode.SAME_THREAD)
+public class MongoCollectionTests {
+ private static MongoServer server;
+ private static MongoClient client;
+ private MongoDatabase db;
+ private MongoCollection people;
+
+ private static final String EXTERNAL_URI = System.getenv("MONGODB_URI");
+
+ @BeforeAll
+ public static void startServer() {
+ if (EXTERNAL_URI != null && !EXTERNAL_URI.isEmpty()) {
+ client = MongoClients.create(EXTERNAL_URI);
+ return;
+ }
+ server = new MongoServer(new MemoryBackend());
+ InetSocketAddress address = server.bind();
+ client = MongoClients.create("mongodb://" + address.getHostString() + ":" + address.getPort());
+ }
+
+ @AfterAll
+ public static void stopServer() {
+ client.close();
+ if (server != null) {
+ server.shutdown();
+ }
+ }
+
+ @BeforeEach
+ public void setUp() {
+ db = client.getDatabase("appmap-java");
+ people = db.getCollection("people");
+ people.drop();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ people.drop();
+ }
+
+ @Test
+ public void crud() {
+ people.insertOne(new Document("name", "alice").append("age", 30));
+ people.insertMany(Arrays.asList(new Document("name", "bob").append("age", 40),
+ new Document("name", "carol").append("age", 50).append("tags", Arrays.asList("x", "y"))));
+
+ people.updateOne(Filters.eq("name", "alice"), Updates.combine(Updates.set("age", 31), Updates.inc("visits", 1)));
+ people.updateMany(Filters.gt("age", 35), Updates.set("senior", true));
+ people.replaceOne(Filters.eq("name", "bob"), new Document("name", "bob").append("age", 41));
+
+ Document alice = people.find(Filters.eq("name", "alice")).first();
+ assertEquals(31, alice.getInteger("age"));
+
+ // bob was replaced after the update, so only carol is still marked senior
+ List seniors = people.find(Filters.and(Filters.in("name", "bob", "carol"), Filters.exists("senior")))
+ .sort(Sorts.descending("age")).into(new ArrayList());
+ assertEquals(1, seniors.size());
+
+ assertEquals(3, people.countDocuments());
+ assertEquals(1, people.countDocuments(Filters.lt("age", 35)));
+ assertEquals(3, people.estimatedDocumentCount());
+
+ List names = people.distinct("name", String.class).into(new ArrayList());
+ assertEquals(3, names.size());
+
+ Document total = people.aggregate(Arrays.asList(Aggregates.match(Filters.gte("age", 31)),
+ Aggregates.group(null, com.mongodb.client.model.Accumulators.sum("total", "$age")))).first();
+ assertEquals(122, total.getInteger("total")); // 31 + 41 + 50
+
+ people.deleteOne(Filters.eq("name", "carol"));
+ people.deleteMany(Filters.in("name", Arrays.asList("alice", "bob")));
+ assertEquals(0, people.countDocuments());
+ }
+
+ @Test
+ public void bulkWriteAndPipelineUpdate() {
+ List> ops = new ArrayList>();
+ ops.add(new InsertOneModel(new Document("name", "dave").append("age", 20)));
+ ops.add(new InsertOneModel(new Document("name", "erin").append("age", 25)));
+ ops.add(new UpdateOneModel(Filters.eq("name", "dave"), Updates.set("age", 21)));
+ people.bulkWrite(ops);
+
+ List pipeline = Arrays.asList(new Document("$set", new Document("adult", true)),
+ new Document("$unset", "age"));
+ try {
+ people.updateMany(Filters.exists("name"), pipeline);
+ } catch (MongoCommandException e) {
+ // mongo-java-server 1.43 does not implement pipeline updates. The
+ // statement is recorded either way.
+ }
+
+ Document found = people.findOneAndUpdate(Filters.eq("name", "erin"), Updates.set("age", 26));
+ assertEquals("erin", found.getString("name"));
+ people.findOneAndDelete(Filters.eq("name", "dave"));
+ }
+
+ @Test
+ public void indexes() {
+ String name = people.createIndex(Indexes.ascending("name"), new IndexOptions().unique(true));
+ List indexes = people.listIndexes().into(new ArrayList());
+ assertTrue(indexes.size() >= 2);
+ people.insertOne(new Document("name", "frank"));
+ // The second insert violates the unique index. The failure must be
+ // recorded on the query event and still be thrown to the application.
+ assertThrows(MongoWriteException.class, () -> people.insertOne(new Document("name", "frank")));
+ people.dropIndex(name);
+ people.dropIndexes();
+ }
+
+ @Test
+ public void pojos() {
+ CodecRegistry pojoRegistry = CodecRegistries.fromRegistries(MongoClientSettings.getDefaultCodecRegistry(),
+ CodecRegistries.fromProviders(PojoCodecProvider.builder().automatic(true).build()));
+ MongoCollection persons = db.getCollection("people", Person.class).withCodecRegistry(pojoRegistry);
+ persons.insertOne(new Person("grace", 35));
+ persons.insertMany(Arrays.asList(new Person("heidi", 36), new Person("ivan", 37)));
+ Person grace = persons.find(Filters.eq("name", "grace")).first();
+ assertEquals(35, grace.getAge());
+ assertEquals(3, persons.countDocuments());
+ }
+
+ @Test
+ public void sessions() {
+ assumeTrue(server == null, "mongo-java-server does not support sessions");
+ // A session is recorded as a parameter and left out of the statement.
+ try (ClientSession session = client.startSession()) {
+ people.insertOne(session, new Document("name", "judy").append("age", 38));
+ people.updateOne(session, Filters.eq("name", "judy"), Updates.set("age", 39));
+ assertEquals(1, people.countDocuments(session));
+ Document judy = people.find(session, Filters.eq("name", "judy")).first();
+ assertEquals(39, judy.getInteger("age"));
+ }
+ }
+
+ @Test
+ public void oddCollectionName() {
+ MongoCollection odd = db.getCollection("with-dash");
+ odd.insertOne(new Document("a", 1));
+ assertEquals(1, odd.countDocuments());
+ odd.drop();
+ }
+}