summaryrefslogtreecommitdiff
path: root/jstests/noPassthrough/queryStats
diff options
context:
space:
mode:
Diffstat (limited to 'jstests/noPassthrough/queryStats')
-rw-r--r--jstests/noPassthrough/queryStats/agg_cmd_one_way_tokenization.js187
-rw-r--r--jstests/noPassthrough/queryStats/application_name_find.js37
-rw-r--r--jstests/noPassthrough/queryStats/clear_query_stats_store.js46
-rw-r--r--jstests/noPassthrough/queryStats/documentSourceQueryStats_redaction_parameters.js126
-rw-r--r--jstests/noPassthrough/queryStats/feature_flag_off_sampling_rate_on.js44
-rw-r--r--jstests/noPassthrough/queryStats/find_cmd_one_way_tokenization.js66
-rw-r--r--jstests/noPassthrough/queryStats/geometry_without_coordinates.js22
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_agg_cmd_collect_on_mongos.js143
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_agg_key.js64
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream.js182
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream_passthrough_shard.js94
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_agg_key_explain.js84
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_change_stream.js227
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_change_stream_evict.js71
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_change_stream_per_shard_cursor.js222
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_collectionType.js208
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_disable_after_initial_request.js98
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_explain_cmd_agg.js37
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_expressions.js76
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_feature_flag.js31
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_find_cmd_collect_on_mongos.js126
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_find_key.js113
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_key_hash.js98
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_logging.js99
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_metrics_across_getMore_calls.js173
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_regex.js43
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_sampling_rate.js37
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_server_status_metrics.js272
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_sub_pipelines.js184
-rw-r--r--jstests/noPassthrough/queryStats/query_stats_upgrade.js45
-rw-r--r--jstests/noPassthrough/queryStats/redact_queries_with_nonobject_fields.js76
-rw-r--r--jstests/noPassthrough/queryStats/redact_sensitive_fields_in_log.js60
-rw-r--r--jstests/noPassthrough/queryStats/redact_sensitive_fields_in_profile.js63
-rw-r--r--jstests/noPassthrough/queryStats/repl_set_query_stats_key.js108
-rw-r--r--jstests/noPassthrough/queryStats/single_batch_on_mongod_agg_cmd.js61
-rw-r--r--jstests/noPassthrough/queryStats/single_batch_on_mongod_find_cmd.js59
36 files changed, 3682 insertions, 0 deletions
diff --git a/jstests/noPassthrough/queryStats/agg_cmd_one_way_tokenization.js b/jstests/noPassthrough/queryStats/agg_cmd_one_way_tokenization.js
new file mode 100644
index 00000000000..27e4b317faa
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/agg_cmd_one_way_tokenization.js
@@ -0,0 +1,187 @@
+/**
+ * Test that $queryStats properly tokenizes aggregation commands, on mongod and mongos.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js");
+(function() {
+"use strict";
+
+const kHashedDbName = "iDlS7h5jf5HHxWPJpeHRbA+jLTNNZaqxVVkplrEkfko=";
+const kHashedCollName = "w6Ax20mVkbJu4wQWAMjL8Sl+DfXAr2Zqdc3kJRB7Oo0=";
+const kHashedFieldA = "GDiF6ZEXkeo4kbKyKEAAViZ+2RHIVxBQV9S6b6Lu7gU=";
+const kHashedFieldB = "m1xtUkfSpZNxXjNZYKwo86vGD37Zxmd2gtt+TXDO558=";
+
+function verifyConsistentFields(key) {
+ assert.eq({"db": `${kHashedDbName}`, "coll": `${kHashedCollName}`}, key.queryShape.cmdNs);
+ assert.eq("aggregate", key.queryShape.command);
+ assert.eq(kShellApplicationName, key.client.application.name);
+}
+
+function runTest(conn) {
+ const db = conn.getDB("testDB");
+ const admin = conn.getDB("admin");
+
+ db.test.drop();
+ db.otherColl.drop();
+ assert.commandWorked(db.test.insert({a: "foobar", b: 15}));
+ assert.commandWorked(db.test.insert({a: "foobar", b: 20}));
+ assert.commandWorked(db.otherColl.insert({a: "foobar", price: 2.50}));
+
+ // First checks proper tokenization on a basic pipeline.
+ {
+ db.test
+ .aggregate([
+ {$sort: {a: -1}},
+ {$match: {a: {$regex: "foo(.*)"}, b: {$gt: 10}}},
+ {$skip: 5},
+ ])
+ .toArray();
+
+ const stats = getQueryStatsAggCmd(admin, {transformIdentifiers: true});
+
+ assert.eq(1,
+ stats.length,
+ {allStats: getQueryStats(admin), metrics: db.serverStatus().metrics.queryStats});
+ const key = stats[0].key;
+ verifyConsistentFields(key);
+ // Make sure there is no otherNss field when there are no secondary namespaces.
+ assert(!key.hasOwnProperty('otherNss'), key);
+ // Ensure the query stats key pipeline holds the raw input without optimization (e.g., the
+ // $sort stays before the $match, as in the raw query).
+ assert.eq(
+ [
+ {"$sort": {[kHashedFieldA]: -1}},
+ {
+ "$match": {
+ "$and": [
+ {[kHashedFieldA]: {"$regex": "?string"}},
+ {[kHashedFieldB]: {"$gt": "?number"}}
+ ]
+ }
+ },
+ {"$skip": "?number"}
+ ],
+ key.queryShape.pipeline,
+ key.queryShape.pipeline);
+ }
+
+ // Checks proper tokenization on another basic pipeline that is a subset of the original
+ // pipeline to make sure there are separate query stats entries per separate query shape.
+ {
+ db.test.aggregate([{$match: {a: {$regex: "foo(.*)"}, b: {$gt: 0}}}]).toArray();
+ const stats = getQueryStatsAggCmd(admin, {transformIdentifiers: true});
+
+ assert.eq(2, stats.length);
+ const key = stats[0].key;
+ verifyConsistentFields(key);
+ // Make sure there is no otherNss field when there are no secondary namespaces.
+ assert(!key.hasOwnProperty('otherNss'), key);
+ assert.eq([{
+ "$match": {
+ "$and": [
+ {[kHashedFieldA]: {"$regex": "?string"}},
+ {[kHashedFieldB]: {"$gt": "?number"}}
+ ]
+ }
+ }],
+ key.queryShape.pipeline,
+ key.queryShape.pipeline);
+ }
+ // Checks proper tokenization on a pipeline that involves a let variable and a $lookup stage
+ // that has its own subpipeline and references another namespace.
+ {
+ const kHashedOtherCollName = "8Rfz9QKu4P3BbyJ3Zpf5kxlUGx7gMvVk2PXZlJVfikE=";
+ const kHashedAsOutputName = "OsoJyz+7myXF2CkbE5dKd9DJ1gDAUw5uyt12k1ENQpY=";
+ const kHashedFieldOrderName = "KcpgS5iaiD5/3BKdQRG5rodz+aEE9FkcTPTYZ+G7cpA=";
+ const kHashedFieldPrice = "LiAftyHzrbrVhwtTPaiHd8Lu9gUILkWgcP682amX7lI=";
+ const kHashedFieldMaxPrice = "lFzklZZ6KbbYMBTi8KtTTp1GZCcPaUKUmOe3iko+IF8=";
+ const kHashedFieldTime = "+TNONlWFBKVmiDN2ujCAexPQ1bat38CEzAQgkn2bvIg=";
+
+ db.test.aggregate([{
+ $lookup: {
+ from: "otherColl",
+ let: { order_name: "$a", price: "$price"},
+ pipeline: [{
+ $match: {
+ $expr: {
+ $and: [
+ { $eq: ["$a", "$$order_name"] },
+ { $lte: ["$$price", "$$max_price"] }
+ ]
+ }
+ }
+ }],
+ as: "my_output"
+ }},
+ {
+ $match: {$expr: {$lte: ["$$CURRENT.time", "$$NOW"]}}
+ }], {let: {max_price: 3.00}}).toArray();
+ const stats = getQueryStatsAggCmd(admin, {transformIdentifiers: true});
+
+ assert.eq(3, stats.length);
+ // This one will sort last because of the 'let' parameters.
+ const key = stats[2].key;
+ verifyConsistentFields(key);
+ assert.eq(
+ [
+ {
+ "$lookup": {
+ "from": `${kHashedOtherCollName}`,
+ "as": `${kHashedAsOutputName}`,
+ "let": {
+ [kHashedFieldOrderName]: asFieldPath(kHashedFieldA),
+ [kHashedFieldPrice]: asFieldPath(kHashedFieldPrice)
+ },
+ "pipeline": [{
+ "$match": {
+ "$expr": {
+ "$and": [
+ {
+ "$eq": [
+ asFieldPath(kHashedFieldA),
+ asVarRef(kHashedFieldOrderName)
+ ],
+ },
+ {
+ "$lte": [
+ asVarRef(kHashedFieldPrice),
+ asVarRef(kHashedFieldMaxPrice)
+ ]
+ }
+ ]
+ }
+ }
+ }]
+ }
+ },
+ {"$match": {"$expr": {"$lte": [asFieldPath(kHashedFieldTime), asVarRef("NOW")]}}}
+ ],
+ key.queryShape.pipeline,
+ key.queryShape.pipeline);
+ assert.eq({[kHashedFieldMaxPrice]: "?number"}, key.queryShape.let);
+ assert.eq([{"db": `${kHashedDbName}`, "coll": `${kHashedOtherCollName}`}], key.otherNss);
+ }
+}
+
+const conn = MongoRunner.runMongod({
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+});
+runTest(conn);
+MongoRunner.stopMongod(conn);
+
+const st = new ShardingTest({
+ mongos: 1,
+ shards: 1,
+ config: 1,
+ rs: {nodes: 1},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+});
+runTest(st.s);
+st.stop();
+}());
diff --git a/jstests/noPassthrough/queryStats/application_name_find.js b/jstests/noPassthrough/queryStats/application_name_find.js
new file mode 100644
index 00000000000..c0dc2bcf848
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/application_name_find.js
@@ -0,0 +1,37 @@
+/**
+ * Test that applicationName and namespace appear in queryStats for the find command.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js");
+(function() {
+"use strict";
+
+const kApplicationName = "MongoDB Shell";
+
+// Turn on the collecting of queryStats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+const conn = MongoRunner.runMongod(options);
+conn.setLogLevel(3, "query");
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+coll.insert({v: 1});
+coll.insert({v: 2});
+coll.insert({v: 3});
+
+coll.find({v: 1}).toArray();
+
+let queryStats = getQueryStats(conn);
+assert.eq(1, queryStats.length, queryStats);
+assert.eq(kApplicationName, queryStats[0].key.client.application.name, queryStats);
+
+queryStats = getQueryStatsFindCmd(conn, {transformIdentifiers: true});
+assert.eq(1, queryStats.length, queryStats);
+assert.eq(kApplicationName, queryStats[0].key.client.application.name, queryStats);
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/clear_query_stats_store.js b/jstests/noPassthrough/queryStats/clear_query_stats_store.js
new file mode 100644
index 00000000000..1aafd2b9b87
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/clear_query_stats_store.js
@@ -0,0 +1,46 @@
+/**
+ * Test that the queryStats store can be cleared when the cache size is reset to 0.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For verifyMetrics and getQueryStats.
+
+(function() {
+"use strict";
+
+// Turn on the collecting of queryStats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1, internalQueryStatsCacheSize: "10MB"},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+let query = {};
+for (var j = 0; j < 10; ++j) {
+ query["foo.field.xyz." + j] = 1;
+ query["bar.field.xyz." + j] = 2;
+ query["baz.field.xyz." + j] = 3;
+ coll.aggregate([{$match: query}]).itcount();
+}
+
+// Confirm number of entries in the store and that none have been evicted.
+let res = getQueryStats(conn);
+assert.eq(res.length, 10, res);
+assert.eq(testDB.serverStatus().metrics.queryStats.numEvicted, 0);
+assert.gt(testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes, 0);
+
+// Command to clear the cache.
+assert.commandWorked(testDB.adminCommand({setParameter: 1, internalQueryStatsCacheSize: "0MB"}));
+
+// 10 regular queries plus the $queryStats query, means 11 entries evicted when the cache is
+// cleared.
+assert.eq(testDB.serverStatus().metrics.queryStats.numEvicted, 11);
+assert.eq(testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes, 0);
+
+// Calling $queryStats should fail when the query stats store size is 0 bytes.
+assert.throwsWithCode(() => testDB.getSiblingDB("admin").aggregate([{$queryStats: {}}]),
+ ErrorCodes.QueryFeatureNotAllowed);
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/documentSourceQueryStats_redaction_parameters.js b/jstests/noPassthrough/queryStats/documentSourceQueryStats_redaction_parameters.js
new file mode 100644
index 00000000000..40db2885967
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/documentSourceQueryStats_redaction_parameters.js
@@ -0,0 +1,126 @@
+/**
+ * Test the $queryStats hmac properties.
+ * @tags: [requires_fcv_60]
+ */
+
+load("jstests/aggregation/extras/utils.js"); // For assertAdminDBErrCodeAndErrMsgContains.
+load("jstests/libs/query_stats_utils.js"); // For getQueryStatsFindCmd
+
+(function() {
+"use strict";
+
+// Assert the expected queryStats key with no hmac.
+function assertQueryStatsKeyWithoutHmac(queryStatsKey) {
+ assert.eq(queryStatsKey.filter, {"foo": {"$lte": "?number"}});
+ assert.eq(queryStatsKey.sort, {"bar": -1});
+ assert.eq(queryStatsKey.limit, "?number");
+}
+
+function runTest(conn) {
+ const testDB = conn.getDB('test');
+ const coll = testDB[jsTestName()];
+ coll.drop();
+
+ coll.insert({foo: 1});
+ coll.find({foo: {$lte: 2}}).sort({bar: -1}).limit(2).toArray();
+ // Default is no hmac.
+ assertQueryStatsKeyWithoutHmac(getQueryStatsFindCmd(conn)[0].key.queryShape);
+
+ // Turning on hmac should apply hmac to all field names on all entries, even previously cached
+ // ones.
+ const queryStatsKey = getQueryStatsFindCmd(conn, {transformIdentifiers: true})[0]["key"];
+ assert.eq(queryStatsKey.queryShape.filter,
+ {"fNWkKfogMv6MJ77LpBcuPrO7Nq+R+7TqtD+Lgu3Umc4=": {"$lte": "?number"}});
+ assert.eq(queryStatsKey.queryShape.sort, {"CDDQIXZmDehLKmQcRxtdOQjMqoNqfI2nGt2r4CgJ52o=": -1});
+ assert.eq(queryStatsKey.queryShape.limit, "?number");
+
+ // Turning hmac back off should preserve field names on all entries, even previously cached
+ // ones.
+ const queryStats = getQueryStatsFindCmd(conn)[0]["key"];
+ assertQueryStatsKeyWithoutHmac(queryStats.queryShape);
+
+ // Explicitly set transformIdentifiers to false.
+ assertQueryStatsKeyWithoutHmac(
+ getQueryStatsFindCmd(conn, {transformIdentifiers: false})[0]["key"].queryShape);
+
+ // Wrong parameter name throws error.
+ let pipeline = [{$queryStats: {redactFields: true}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll, pipeline, 40415, "BSON field '$queryStats.redactFields' is an unknown field.");
+
+ // Wrong parameter name throws error.
+ pipeline = [{$queryStats: {algorithm: "hmac-sha-256"}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll, pipeline, 40415, "BSON field '$queryStats.algorithm' is an unknown field.");
+
+ // Wrong parameter type throws error.
+ pipeline = [{$queryStats: {transformIdentifiers: {algorithm: 1}}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll,
+ pipeline,
+ ErrorCodes.TypeMismatch,
+ "BSON field '$queryStats.transformIdentifiers.algorithm' is the wrong type 'double', expected type 'string'");
+
+ pipeline = [{$queryStats: {transformIdentifiers: {algorithm: "hmac-sha-256", hmacKey: 1}}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll,
+ pipeline,
+ ErrorCodes.TypeMismatch,
+ "BSON field '$queryStats.transformIdentifiers.hmacKey' is the wrong type 'double', expected type 'binData'");
+
+ // Unsupported algorithm throws error.
+ pipeline = [{$queryStats: {transformIdentifiers: {algorithm: "hmac-sha-1"}}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll,
+ pipeline,
+ ErrorCodes.BadValue,
+ "Enumeration value 'hmac-sha-1' for field '$queryStats.transformIdentifiers.algorithm' is not a valid value.");
+
+ // TransformIdentifiers with missing algorithm throws error.
+ pipeline = [{$queryStats: {transformIdentifiers: {}}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll,
+ pipeline,
+ 40414,
+ "BSON field '$queryStats.transformIdentifiers.algorithm' is missing but a required field");
+
+ // TransformIdentifiers with algorithm but missing hmacKey throws error.
+ pipeline = [{$queryStats: {transformIdentifiers: {algorithm: "hmac-sha-256"}}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll,
+ pipeline,
+ ErrorCodes.FailedToParse,
+ "The 'hmacKey' parameter of the $queryStats stage must be specified when applying the hmac-sha-256 algorithm");
+
+ // Parameter object with unrecognized key throws error.
+ pipeline =
+ [{$queryStats: {transformIdentifiers: {algorithm: "hmac-sha-256", hmacStrategy: "on"}}}];
+ assertAdminDBErrCodeAndErrMsgContains(
+ coll,
+ pipeline,
+ 40415,
+ "BSON field '$queryStats.transformIdentifiers.hmacStrategy' is an unknown field.");
+}
+
+const conn = MongoRunner.runMongod({
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+});
+runTest(conn);
+MongoRunner.stopMongod(conn);
+
+const st = new ShardingTest({
+ mongos: 1,
+ shards: 1,
+ config: 1,
+ rs: {nodes: 1},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+});
+runTest(st.s);
+st.stop();
+}());
diff --git a/jstests/noPassthrough/queryStats/feature_flag_off_sampling_rate_on.js b/jstests/noPassthrough/queryStats/feature_flag_off_sampling_rate_on.js
new file mode 100644
index 00000000000..77388690062
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/feature_flag_off_sampling_rate_on.js
@@ -0,0 +1,44 @@
+/**
+ * Test that calls to read from query stats store fail when feature flag is turned off and sampling
+ * rate > 0.
+ */
+load('jstests/libs/analyze_plan.js');
+load("jstests/libs/feature_flag_util.js");
+
+(function() {
+"use strict";
+
+// Set sampling rate to -1.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1, featureFlagQueryStats: false},
+};
+const conn = MongoRunner.runMongod(options);
+const testdb = conn.getDB('test');
+
+var coll = testdb[jsTestName()];
+coll.drop();
+
+// Bulk insert documents to reduces roundtrips and make timeout on a slow machine less likely.
+const bulk = coll.initializeUnorderedBulkOp();
+for (let i = 1; i <= 20; i++) {
+ bulk.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+}
+assert.commandWorked(bulk.execute());
+
+// Pipeline to read queryStats store should fail without feature flag turned on even though sampling
+// rate is > 0.
+assert.commandFailedWithCode(
+ testdb.adminCommand({aggregate: 1, pipeline: [{$queryStats: {}}], cursor: {}}),
+ ErrorCodes.QueryFeatureNotAllowed);
+
+// Pipeline, with a filter, to read queryStats store fails without feature flag turned on even
+// though sampling rate is > 0.
+assert.commandFailedWithCode(testdb.adminCommand({
+ aggregate: 1,
+ pipeline: [{$queryStats: {}}, {$match: {"key.queryShape.find": {$eq: "###"}}}],
+ cursor: {}
+}),
+ ErrorCodes.QueryFeatureNotAllowed);
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/find_cmd_one_way_tokenization.js b/jstests/noPassthrough/queryStats/find_cmd_one_way_tokenization.js
new file mode 100644
index 00000000000..55a8f322f90
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/find_cmd_one_way_tokenization.js
@@ -0,0 +1,66 @@
+/**
+ * Test that $queryStats properly tokenizes find commands, on mongod and mongos.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js");
+(function() {
+"use strict";
+
+const kHashedFieldName = "lU7Z0mLRPRUL+RfAD5jhYPRRpXBsZBxS/20EzDwfOG4=";
+
+function runTest(conn) {
+ const db = conn.getDB("test");
+ const admin = conn.getDB("admin");
+
+ db.test.drop();
+ db.test.insert({v: 1});
+
+ db.test.find({v: 1}).toArray();
+
+ let queryStats = getQueryStatsFindCmd(admin, {transformIdentifiers: true});
+
+ assert.eq(1, queryStats.length);
+ assert.eq("find", queryStats[0].key.queryShape.command);
+ assert.eq({[kHashedFieldName]: {$eq: "?number"}}, queryStats[0].key.queryShape.filter);
+
+ db.test.insert({v: 2});
+
+ const cursor = db.test.find({v: {$gt: 0, $lt: 3}}).batchSize(1);
+ queryStats = getQueryStatsFindCmd(admin, {transformIdentifiers: true});
+ // Cursor isn't exhausted, so there shouldn't be another entry yet.
+ assert.eq(1, queryStats.length);
+
+ assert.commandWorked(
+ db.runCommand({getMore: cursor.getId(), collection: db.test.getName(), batchSize: 2}));
+
+ queryStats = getQueryStatsFindCmd(admin, {transformIdentifiers: true});
+ assert.eq(2, queryStats.length);
+ assert.eq("find", queryStats[1].key.queryShape.command);
+ assert.eq({
+ "$and": [{[kHashedFieldName]: {"$gt": "?number"}}, {[kHashedFieldName]: {"$lt": "?number"}}]
+ },
+ queryStats[1].key.queryShape.filter);
+}
+
+let conn = MongoRunner.runMongod({
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+});
+runTest(conn);
+MongoRunner.stopMongod(conn);
+
+let st = new ShardingTest({
+ mongos: 1,
+ shards: 1,
+ config: 1,
+ rs: {nodes: 1},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+});
+runTest(st.s);
+st.stop();
+}());
diff --git a/jstests/noPassthrough/queryStats/geometry_without_coordinates.js b/jstests/noPassthrough/queryStats/geometry_without_coordinates.js
new file mode 100644
index 00000000000..72a5015bfab
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/geometry_without_coordinates.js
@@ -0,0 +1,22 @@
+// This test was designed to reproduce SERVER-77430. There was a mistaken assertion in a parser that
+// we are interested in proving will not fail here.
+// @tags: [requires_fcv_60]
+(function() {
+"use strict";
+
+const st = new ShardingTest({
+ mongos: 1,
+ shards: 1,
+ config: 1,
+ rs: {nodes: 1},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+});
+const coll = st.s.getDB("test").geometry_without_coordinates;
+// This is a query that once mistakenly threw an error.
+assert.doesNotThrow(() => coll.find({geo: {$geoIntersects: {$geometry: {x: 40, y: 5}}}}).itcount());
+st.stop();
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_agg_cmd_collect_on_mongos.js b/jstests/noPassthrough/queryStats/query_stats_agg_cmd_collect_on_mongos.js
new file mode 100644
index 00000000000..30cdcce8a18
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_agg_cmd_collect_on_mongos.js
@@ -0,0 +1,143 @@
+/**
+ * Test that mongos is collecting query stats metrics for agg queries.
+ * @tags: [requires_fcv_60]
+ */
+
+load('jstests/libs/query_stats_utils.js');
+
+(function() {
+"use strict";
+
+const st = new ShardingTest({
+ mongos: 1,
+ shards: 1,
+ config: 1,
+ rs: {nodes: 1},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+});
+const mongos = st.s;
+const db = mongos.getDB("test");
+
+// Assert that, for agg queries, no query stats results are written until a cursor has reached
+// exhaustion; ensure accurate results once they're written.
+{
+ const coll = db.coll;
+ coll.insert({v: 1});
+ coll.insert({v: 4});
+ const queryStatsKey = {
+ queryShape: {
+ cmdNs: {db: "test", coll: "coll"},
+ command: "aggregate",
+ pipeline: [
+ {$match: {$and: [{v: {$gt: "?number"}}, {v: {$lt: "?number"}}]}},
+ {$project: {_id: true, hello: true}}
+ ]
+
+ },
+ cursor: {batchSize: "?number"},
+ applicationName: "MongoDB Shell",
+ };
+
+ const cursor = coll.aggregate(
+ [
+ {$match: {v: {$gt: 0, $lt: 5}}},
+ {$project: {hello: true}},
+ ],
+ {cursor: {batchSize: 1}}); // returns 1 doc
+
+ // Since the cursor hasn't been exhausted yet, ensure no query stats results have been written
+ // yet.
+ let queryStats = getQueryStats(db);
+ assert.eq(0, queryStats.length, queryStats);
+
+ // Run a getMore to exhaust the cursor, then ensure query stats results have been written
+ // accurately. batchSize must be 2 so the cursor recognizes exhaustion.
+ assert.commandWorked(db.runCommand({
+ getMore: cursor.getId(),
+ collection: coll.getName(),
+ batchSize: 2
+ })); // returns 1 doc, exhausts the cursor
+ queryStats = getQueryStatsAggCmd(db);
+ assert.eq(1, queryStats.length, queryStats);
+ assertExpectedResults(queryStats[0],
+ queryStatsKey,
+ /* expectedExecCount */ 1,
+ /* expectedDocsReturnedSum */ 2,
+ /* expectedDocsReturnedMax */ 2,
+ /* expectedDocsReturnedMin */ 2,
+ /* expectedDocsReturnedSumOfSq */ NumberDecimal("4"),
+ /* getMores */ true);
+
+ // Run more queries (to exhaustion) with the same query shape, and ensure query stats results
+ // are accurate.
+ coll.aggregate([
+ {$match: {v: {$gt: 0, $lt: 5}}},
+ {$project: {hello: true}},
+ ]); // returns 2 docs
+ coll.aggregate([
+ {$match: {v: {$gt: 2, $lt: 3}}},
+ {$project: {hello: true}},
+ ]); // returns 0 docs
+ coll.aggregate([
+ {$match: {v: {$gt: 0, $lt: 2}}},
+ {$project: {hello: true}},
+ ]); // returns 1 doc
+ queryStats = getQueryStatsAggCmd(db);
+ assert.eq(1, queryStats.length, queryStats);
+ assertExpectedResults(queryStats[0],
+ queryStatsKey,
+ /* expectedExecCount */ 4,
+ /* expectedDocsReturnedSum */ 5,
+ /* expectedDocsReturnedMax */ 2,
+ /* expectedDocsReturnedMin */ 0,
+ /* expectedDocsReturnedSumOfSq */ NumberDecimal("9"),
+ /* getMores */ true);
+}
+
+// Assert on batchSize-limited agg queries that killCursors will write metrics with partial results
+// to the query stats store.
+{
+ const coll = db.coll2;
+ coll.insert({v: 1});
+ coll.insert({v: 4});
+
+ const queryStatsKey = {
+ queryShape: {
+ cmdNs: {db: "test", coll: "coll2"},
+ command: "aggregate",
+ pipeline: [{$match: {$and: [{v: {$gt: "?number"}}, {v: {$lt: "?number"}}]}}]
+ },
+ cursor: {batchSize: "?number"},
+ applicationName: "MongoDB Shell",
+ };
+
+ const cursor1 = coll.aggregate(
+ [
+ {$match: {v: {$gt: 0, $lt: 5}}},
+ ],
+ {cursor: {batchSize: 1}}); // returns 1 doc
+ const cursor2 = coll.aggregate(
+ [
+ {$match: {v: {$gt: 0, $lt: 2}}},
+ ],
+ {cursor: {batchSize: 1}}); // returns 1 doc
+
+ assert.commandWorked(
+ db.runCommand({killCursors: coll.getName(), cursors: [cursor1.getId(), cursor2.getId()]}));
+ const queryStats = getLatestQueryStatsEntry(db, {collName: coll.getName()});
+ assertExpectedResults(queryStats,
+ queryStatsKey,
+ /* expectedExecCount */ 2,
+ /* expectedDocsReturnedSum */ 2,
+ /* expectedDocsReturnedMax */ 1,
+ /* expectedDocsReturnedMin */ 1,
+ /* expectedDocsReturnedSumOfSq */ NumberDecimal("2"),
+ /* getMores */ false);
+}
+
+st.stop();
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_agg_key.js b/jstests/noPassthrough/queryStats/query_stats_agg_key.js
new file mode 100644
index 00000000000..0eec5525733
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_agg_key.js
@@ -0,0 +1,64 @@
+/**
+ * This test confirms that query stats store key fields for an aggregate command are properly nested
+ * and none are missing.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For runCommandAndValidateQueryStats and
+ // withQueryStatsEnabled
+(function() {
+"use strict";
+
+const collName = jsTestName();
+const aggregateCommandObj = {
+ aggregate: collName,
+ pipeline: [{"$out": "collOut"}],
+ allowDiskUse: false,
+ cursor: {batchSize: 2},
+ maxTimeMS: 50 * 1000,
+ bypassDocumentValidation: false,
+ readConcern: {level: "local"},
+ writeConcern: {w: 1},
+ collation: {locale: "en_US", strength: 2},
+ hint: {"v": 1},
+ comment: "",
+ let : {},
+ apiDeprecationErrors: false,
+ apiVersion: "1",
+ apiStrict: false,
+};
+
+const queryShapeAggregateFields =
+ ["cmdNs", "command", "pipeline", "allowDiskUse", "collation", "let"];
+
+// The outer fields not nested inside queryShape.
+const queryStatsAggregateKeyFields = [
+ "queryShape",
+ "cursor",
+ "maxTimeMS",
+ "bypassDocumentValidation",
+ "comment",
+ "otherNss",
+ "apiDeprecationErrors",
+ "apiVersion",
+ "apiStrict",
+ "collectionType",
+ "client",
+ "hint",
+ "readConcern",
+ "writeConcern",
+ "cursor.batchSize",
+];
+
+withQueryStatsEnabled(collName, (coll) => {
+ // Have to create an index for hint not to fail.
+ assert.commandWorked(coll.createIndex({v: 1}));
+
+ runCommandAndValidateQueryStats({
+ coll: coll,
+ commandName: "aggregate",
+ commandObj: aggregateCommandObj,
+ shapeFields: queryShapeAggregateFields,
+ keyFields: queryStatsAggregateKeyFields
+ });
+});
+}()); \ No newline at end of file
diff --git a/jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream.js b/jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream.js
new file mode 100644
index 00000000000..63f917c6c07
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream.js
@@ -0,0 +1,182 @@
+/**
+ * This test confirms that query stats store key fields for an aggregate command are properly nested
+ * and none are missing. It also validates the exact pipeline in the query shape.
+ * @tags: [
+ * uses_change_streams,
+ * requires_fcv_60
+ * ]
+ */
+
+load("jstests/libs/query_stats_utils.js"); // For runCommandAndValidateQueryStats.
+load("jstests/libs/collection_drop_recreate.js"); // For assertDropAndRecreateCollection.
+const dbName = jsTestName();
+const collName = "coll";
+
+const queryShapeAggregateFields =
+ ["cmdNs", "command", "pipeline", "allowDiskUse", "collation", "let"];
+
+// The outer fields not nested inside queryShape.
+const queryStatsAggregateKeyFields = [
+ "queryShape",
+ "cursor",
+ "maxTimeMS",
+ "bypassDocumentValidation",
+ "comment",
+ "apiDeprecationErrors",
+ "apiVersion",
+ "apiStrict",
+ "collectionType",
+ "client",
+ "hint",
+ "readConcern",
+ "cursor.batchSize",
+];
+
+const testCases = [
+ // Default fields.
+ {
+ pipeline: [{"$changeStream": {}}],
+ expectedShapifiedPipeline: [{
+ "$changeStream": {
+ startAtOperationTime: "?timestamp",
+ fullDocument: "default",
+ fullDocumentBeforeChange: "off"
+ }
+ }]
+ },
+ // Non default field values.
+ {
+ pipeline: [{
+ "$changeStream": {
+ fullDocument: "updateLookup",
+ fullDocumentBeforeChange: "required",
+ showExpandedEvents: true,
+ }
+ }],
+ expectedShapifiedPipeline: [{
+ "$changeStream": {
+ startAtOperationTime: "?timestamp",
+ fullDocument: "updateLookup",
+ fullDocumentBeforeChange: "required",
+ showExpandedEvents: true,
+ }
+ }],
+ },
+ // $changeStream followed by a $match. $changeStream internally creates another $match stage
+ // which shouldn't appear in the query shape, but a $match in the user specified pipeline should
+ // appear in the query shape.
+ {
+ pipeline: [{$changeStream: {}}, {$match: {a: "field"}}],
+ expectedShapifiedPipeline: [
+ {
+ "$changeStream": {
+ startAtOperationTime: "?timestamp",
+ fullDocument: "default",
+ fullDocumentBeforeChange: "off"
+ }
+ },
+ {$match: {a: {$eq: "?string"}}}
+ ]
+ }
+];
+
+function assertPipelineField(conn, expectedPipeline) {
+ const entry = getLatestQueryStatsEntry(conn, {collName: collName});
+ const statsPipeline = getValueAtPath(entry, "key.queryShape.pipeline");
+ assert.eq(statsPipeline, expectedPipeline);
+}
+
+function validateResumeTokenQueryShape(conn, coll) {
+ // Start a change stream.
+ const changeStream = coll.watch([]);
+
+ // Going to create an invalid event by checking a change stream on a dropped collection.
+ assert.commandWorked(coll.insert({_id: 1}));
+ assert(coll.drop());
+ assert.soon(() => changeStream.hasNext());
+ changeStream.next();
+ const invalidateResumeToken = changeStream.getResumeToken();
+
+ // Resume the change stream using 'startAfter' field.
+ coll.watch([], {startAfter: invalidateResumeToken});
+ assert.commandWorked(coll.insert({_id: 2}));
+
+ const expectedShapifiedPipeline = [{
+ "$changeStream": {
+ startAfter: {_data: "?string"},
+ fullDocument: "default",
+ fullDocumentBeforeChange: "off"
+ }
+ }];
+ assertPipelineField(conn, expectedShapifiedPipeline);
+}
+
+function validateChangeStreamAggKey(conn) {
+ const db = conn.getDB("test");
+ assertDropAndRecreateCollection(db, collName);
+
+ // Change streams with 'startAfter' or 'resumeAfter' are only executed after a certain event and
+ // require re-parsing a resume token. To validate the query shape of these pipelines, we have to
+ // execute the events to register the pipeline.
+ validateResumeTokenQueryShape(conn, db[collName]);
+
+ // Validate the key for the rest of the pipelines.
+ testCases.forEach(input => {
+ const pipeline = input.pipeline;
+ const aggCmdObj = {
+ aggregate: collName,
+ pipeline: pipeline,
+ allowDiskUse: false,
+ cursor: {batchSize: 2},
+ maxTimeMS: 50 * 1000,
+ bypassDocumentValidation: false,
+ readConcern: {level: "majority"},
+ collation: {locale: "en_US", strength: 2},
+ hint: {"v": 1},
+ comment: "",
+ let : {},
+ apiDeprecationErrors: false,
+ apiVersion: "1",
+ apiStrict: false,
+ };
+
+ runCommandAndValidateQueryStats({
+ coll: db[collName],
+ commandName: "aggregate",
+ commandObj: aggCmdObj,
+ shapeFields: queryShapeAggregateFields,
+ keyFields: queryStatsAggregateKeyFields
+ });
+ assertPipelineField(conn, input.expectedShapifiedPipeline);
+ });
+}
+
+{
+ // Test on a sharded cluster.
+ const st = new ShardingTest({
+ mongos: 1,
+ shards: 2,
+ config: 1,
+ rs: {nodes: 1, setParameter: {writePeriodicNoops: true, periodicNoopIntervalSecs: 1}},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+ });
+ validateChangeStreamAggKey(st.s);
+ st.stop();
+}
+
+{
+ // Test the non-sharded case.
+ const rst = new ReplSetTest({nodes: 2});
+ rst.startSet({setParameter: {internalQueryStatsRateLimit: -1}});
+ rst.initiate();
+ rst.getPrimary().getDB("admin").setLogLevel(3, "queryStats");
+
+ // Only aggregations run on replica sets have the '$readPreference' field in the key.
+ queryStatsAggregateKeyFields.push("$readPreference");
+ validateChangeStreamAggKey(rst.getPrimary());
+ rst.stopSet();
+}
diff --git a/jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream_passthrough_shard.js b/jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream_passthrough_shard.js
new file mode 100644
index 00000000000..3a8123021d2
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_agg_key_change_stream_passthrough_shard.js
@@ -0,0 +1,94 @@
+/**
+ * This test confirms that query stats store key fields for an aggregate command are properly nested
+ * and none are missing when running a change stream query with $_passthroughToShard.
+ * @tags: [
+ * requires_sharding,
+ * uses_change_streams,
+ * requires_fcv_60
+ * ]
+ */
+
+load("jstests/libs/query_stats_utils.js");
+const dbName = jsTestName();
+const collName = "coll";
+
+// $_passthroughToShard is only possible on a sharded cluster.
+const st = new ShardingTest({
+ shards: 2,
+ mongos: 1,
+ config: 1,
+ rs: {nodes: 1},
+ other: {
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ }
+ }
+});
+
+const sdb = st.s0.getDB(dbName);
+assert.commandWorked(sdb.dropDatabase());
+
+sdb.setProfilingLevel(0, -1);
+st.shard0.getDB(dbName).setProfilingLevel(0, -1);
+
+// Shard the relevant collections.
+assert.commandWorked(st.s.adminCommand({enableSharding: dbName, primaryShard: st.shard0.name}));
+// Shard the collection on {_id: 1}, split at {_id: 0} and move the empty upper chunk to
+// shard1.
+st.shardColl(collName, {_id: 1}, {_id: 0}, {_id: 0}, dbName);
+
+const shardId = st.shard0.shardName;
+let coll = sdb[collName];
+
+const aggregateCommandObj = {
+ aggregate: coll.getName(),
+ pipeline: [{"$changeStream": {}}],
+ allowDiskUse: false,
+ cursor: {batchSize: 2},
+ maxTimeMS: 50 * 1000,
+ bypassDocumentValidation: false,
+ readConcern: {level: "majority"},
+ collation: {locale: "en_US", strength: 2},
+ hint: {"v": 1},
+ comment: "",
+ let : {},
+ apiDeprecationErrors: false,
+ apiVersion: "1",
+ apiStrict: false,
+ $_passthroughToShard: {shard: shardId}
+};
+
+const queryShapeAggregateFields =
+ ["cmdNs", "command", "pipeline", "allowDiskUse", "collation", "let"];
+
+// The outer fields not nested inside queryShape.
+const queryStatsAggregateKeyFields = [
+ "queryShape",
+ "cursor",
+ "maxTimeMS",
+ "bypassDocumentValidation",
+ "comment",
+ "apiDeprecationErrors",
+ "apiVersion",
+ "apiStrict",
+ "collectionType",
+ "client",
+ "hint",
+ "readConcern",
+ "cursor.batchSize",
+ "$_passthroughToShard",
+ "$_passthroughToShard.shard"
+];
+assert.commandWorked(coll.createIndex({v: 1}));
+
+runCommandAndValidateQueryStats({
+ coll: coll,
+ commandName: "aggregate",
+ commandObj: aggregateCommandObj,
+ shapeFields: queryShapeAggregateFields,
+ keyFields: queryStatsAggregateKeyFields
+});
+
+st.stop(); \ No newline at end of file
diff --git a/jstests/noPassthrough/queryStats/query_stats_agg_key_explain.js b/jstests/noPassthrough/queryStats/query_stats_agg_key_explain.js
new file mode 100644
index 00000000000..4d1b68c7b6d
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_agg_key_explain.js
@@ -0,0 +1,84 @@
+/**
+ * This test confirms that query stats store key fields for an aggregate command are properly nested
+ * and none are missing when running an explain query.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js");
+
+const collName = jsTestName();
+
+const aggregateCommandObj = {
+ aggregate: collName,
+ pipeline: [{"$out": "collOut"}],
+ allowDiskUse: false,
+ cursor: {batchSize: 2},
+ maxTimeMS: 50 * 1000,
+ bypassDocumentValidation: false,
+ readConcern: {level: "local"},
+ // This flag sets the explain verbosity to 'queryPlanner'.
+ explain: true,
+ collation: {locale: "en_US", strength: 2},
+ hint: {"v": 1},
+ comment: "",
+ let : {},
+ apiDeprecationErrors: false,
+ apiVersion: "1",
+ apiStrict: false,
+};
+
+const queryShapeAggregateFields =
+ ["cmdNs", "command", "pipeline", "allowDiskUse", "collation", "let"];
+
+// The outer fields not nested inside queryShape.
+const queryStatsAggregateKeyFields = [
+ "queryShape",
+ "cursor",
+ "maxTimeMS",
+ "bypassDocumentValidation",
+ "comment",
+ "otherNss",
+ "apiDeprecationErrors",
+ "apiVersion",
+ "apiStrict",
+ "collectionType",
+ "client",
+ "hint",
+ "readConcern",
+ "explain",
+ "cursor.batchSize",
+];
+
+// Copy the command object to test {explain: false}.
+const aggregateCommandObjExplainFalse = Object.assign({}, aggregateCommandObj);
+aggregateCommandObjExplainFalse.explain = false;
+
+// Setting the explain flag to 'false' has the same behavior as having no explain flag at all,
+// making it not appear on the key.
+const queryStatsAggregateKeyFieldsExplainFalse =
+ queryStatsAggregateKeyFields.filter(e => e !== "explain");
+
+withQueryStatsEnabled(collName, (coll) => {
+ // Create an index for hint not to fail.
+ assert.commandWorked(coll.createIndex({v: 1}));
+
+ // Run an aggregate with {explain: true} and make sure that the 'explain'
+ // field shows up in the query stats store key.
+ runCommandAndValidateQueryStats({
+ coll: coll,
+ commandName: "aggregate",
+ commandObj: aggregateCommandObj,
+ shapeFields: queryShapeAggregateFields,
+ keyFields: queryStatsAggregateKeyFields
+ });
+
+ // Run an aggregate with {explain: false} and make sure that the 'explain'
+ // field doesn't shows up in the query stats store key.
+ runCommandAndValidateQueryStats({
+ coll: coll,
+ commandName: "aggregate",
+ commandObj: aggregateCommandObjExplainFalse,
+ // The shape remains the same since 'explain' is at the key level.
+ shapeFields: queryShapeAggregateFields,
+ keyFields: queryStatsAggregateKeyFieldsExplainFalse
+ });
+});
diff --git a/jstests/noPassthrough/queryStats/query_stats_change_stream.js b/jstests/noPassthrough/queryStats/query_stats_change_stream.js
new file mode 100644
index 00000000000..a610ff5f0bc
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_change_stream.js
@@ -0,0 +1,227 @@
+// Tests the collection of query stats for a change stream query.
+// @tags: [
+// uses_change_streams,
+// requires_replication,
+// requires_sharding,
+// requires_fcv_60
+// ]
+
+load("jstests/libs/query_stats_utils.js"); // For getLatestQueryStatsEntry.
+load("jstests/libs/fixture_helpers.js"); // For FixtureHelpers.
+load("jstests/libs/collection_drop_recreate.js"); // For assertDropAndRecreateCollection.
+
+function testCollectionChangeStream(conn) {
+ const db = conn.getDB("test");
+ assertDropAndRecreateCollection(db, "coll");
+
+ // Check change stream explain is recorded.
+ assert.commandWorked(
+ db.coll.explain({"verbosity": "queryPlanner"}).aggregate([{"$changeStream": {}}]));
+ let queryStatsEntry = getLatestQueryStatsEntry(db);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: db,
+ collectionName: "coll",
+ numExecs: 1,
+ numDocsReturned: 0
+ });
+ assert(queryStatsEntry.key.hasOwnProperty("explain"));
+
+ // Check creation of change stream cursor is recorded.
+ let cursor = db.coll.watch([], {batchSize: 1});
+
+ let numExecs = 1;
+ let numDocsReturned = 0;
+
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(db),
+ db: db,
+ collectionName: "coll",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Insert document into a collection and make sure retrieving the information updates the query
+ // stats entry.
+ assert.commandWorked(db.coll.insert({_id: 0, a: 1}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(cursor);
+ numDocsReturned++;
+
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(db),
+ db: db,
+ collectionName: "coll",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Close cursor and check that it updates query stats entry.
+ cursor.close();
+ numExecs++;
+ queryStatsEntry = getLatestQueryStatsEntry(db);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: db,
+ collectionName: "coll",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+ // Closing the cursor should result in 0 ms for the operation.
+ assert.eq(queryStatsEntry.metrics.lastExecutionMicros, 0);
+}
+
+function testDatabaseChangeStream(conn) {
+ const db = conn.getDB("test");
+ assertDropAndRecreateCollection(db, "coll1");
+ assertDropAndRecreateCollection(db, "coll2");
+
+ // Check creation of change stream cursor is recorded.
+ let wholeDBCursor = db.watch([]);
+ let numExecs = 1;
+ let numDocsReturned = 0;
+
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(db),
+ db: db,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Insert document into a collection and make sure retrieving the information updates the query
+ // stats entry.
+ assert.commandWorked(db.coll1.insert({_id: 1, a: 2}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(wholeDBCursor);
+ numDocsReturned++;
+
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(db),
+ db: db,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Insert document into a different collection and make sure retrieving the information updates
+ // the query stats entry.
+ assert.commandWorked(db.coll2.insert({_id: 1, a: 2}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(wholeDBCursor);
+ numDocsReturned++;
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(db),
+ db: db,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Close cursor and check that it increases numExecs.
+ wholeDBCursor.close();
+ numExecs++;
+ let queryStatsEntry = getLatestQueryStatsEntry(db);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: db,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Closing the cursor should result in 0 ms for the operation.
+ assert.eq(queryStatsEntry.metrics.lastExecutionMicros, 0);
+}
+
+function testWholeClusterChangeStream(conn) {
+ const dbA = conn.getDB("testA");
+ const dbB = conn.getDB("testB");
+ assertDropAndRecreateCollection(dbA, "collA");
+ assertDropAndRecreateCollection(dbB, "collB");
+
+ // Creating the change stream cursor should create a query stats entry.
+ let wholeClusterCursor = dbA.getMongo().watch([]);
+ let numExecs = 1;
+ let numDocsReturned = 0;
+
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(dbA),
+ db: dbA,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Insert document into a database and make sure retrieving the information updates the query
+ // stats entry.
+ assert.commandWorked(dbA.collA.insert({_id: 1, a: 2}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(wholeClusterCursor);
+ numDocsReturned++;
+
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(dbA),
+ db: dbA,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Insert document into different database and make sure retrieving the information updates the
+ // query stats entry.
+ assert.commandWorked(dbB.collB.insert({_id: 1, a: 2}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(wholeClusterCursor);
+ numDocsReturned++;
+
+ checkChangeStreamEntry({
+ queryStatsEntry: getLatestQueryStatsEntry(dbA),
+ db: dbA,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ // Close cursor and check that it increases numExecs.
+ wholeClusterCursor.close();
+ numExecs++;
+ let queryStatsEntry = getLatestQueryStatsEntry(dbA);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: dbA,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+ // Closing the cursor should result in 0 ms for the operation.
+ assert.eq(queryStatsEntry.metrics.lastExecutionMicros, 0);
+}
+
+function runTest(conn) {
+ testCollectionChangeStream(conn);
+ testDatabaseChangeStream(conn);
+ testWholeClusterChangeStream(conn);
+}
+
+{
+ // Test the non-sharded case.
+ const rst = new ReplSetTest({nodes: 2});
+ rst.startSet({setParameter: {internalQueryStatsRateLimit: -1}});
+ rst.initiate();
+ rst.getPrimary().getDB("admin").setLogLevel(3, "queryStats");
+ runTest(rst.getPrimary());
+ rst.stopSet();
+}
+
+{
+ // Test on a sharded cluster.
+ const st = new ShardingTest({
+ mongos: 1,
+ shards: 2,
+ config: 1,
+ rs: {nodes: 1, setParameter: {writePeriodicNoops: true, periodicNoopIntervalSecs: 1}},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+ });
+ runTest(st.s);
+ st.stop();
+}
diff --git a/jstests/noPassthrough/queryStats/query_stats_change_stream_evict.js b/jstests/noPassthrough/queryStats/query_stats_change_stream_evict.js
new file mode 100644
index 00000000000..c9782f4388e
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_change_stream_evict.js
@@ -0,0 +1,71 @@
+// Tests the collection of query stats for a change stream query if the entry is evicted while the
+// cursor is still active.
+// @tags: [
+// uses_change_streams,
+// requires_replication,
+// requires_sharding,
+// requires_fcv_60
+// ]
+load("jstests/libs/query_stats_utils.js"); // For getLatestQueryStatsEntry, getQueryStats,
+ // resetQueryStatsStore.
+load("jstests/libs/fixture_helpers.js"); // For FixtureHelpers.
+load("jstests/libs/collection_drop_recreate.js"); // For assertDropAndRecreateCollection.
+
+function runTest(conn) {
+ const db = conn.getDB("test");
+ assertDropAndRecreateCollection(db, "coll");
+
+ // Check creation of change stream cursor is recorded.
+ let cursor = db.coll.watch([]);
+
+ // Check that change stream entry was recorded.
+ let queryStatsEntry = getLatestQueryStatsEntry(db);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: db,
+ collectionName: "coll",
+ numExecs: 1,
+ numDocsReturned: 0
+ });
+
+ // Reset the store to evict the change streams metric.
+ resetQueryStatsStore(db, "1MB");
+
+ // Insert document into a collection which should update the cursor.
+ assert.commandWorked(db.coll.insert({_id: 0, a: 1}));
+ assert.soon(() => cursor.hasNext());
+
+ // There should be nothing stored in the query stats store.
+ let queryStats = getQueryStats(db);
+ assert.eq(queryStats, []);
+
+ // Close cursor.
+ cursor.close();
+}
+
+{
+ // Test the non-sharded case.
+ const rst = new ReplSetTest({nodes: 2});
+ rst.startSet({setParameter: {internalQueryStatsRateLimit: -1}});
+ rst.initiate();
+ rst.getPrimary().getDB("admin").setLogLevel(3, "queryStats");
+ runTest(rst.getPrimary());
+ rst.stopSet();
+}
+
+{
+ // Test on a sharded cluster.
+ const st = new ShardingTest({
+ mongos: 1,
+ shards: 2,
+ config: 1,
+ rs: {nodes: 1, setParameter: {writePeriodicNoops: true, periodicNoopIntervalSecs: 1}},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+ });
+ runTest(st.s);
+ st.stop();
+} \ No newline at end of file
diff --git a/jstests/noPassthrough/queryStats/query_stats_change_stream_per_shard_cursor.js b/jstests/noPassthrough/queryStats/query_stats_change_stream_per_shard_cursor.js
new file mode 100644
index 00000000000..d2147f73eeb
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_change_stream_per_shard_cursor.js
@@ -0,0 +1,222 @@
+/**
+ * Tests the collection of query stats for a change stream query created using $_passthroughToShard.
+ * @tags: [
+ * requires_sharding,
+ * uses_change_streams,
+ * requires_fcv_60
+ * ]
+ */
+load("jstests/libs/query_stats_utils.js");
+
+const dbName = jsTestName();
+
+function testCollectionChangeStream(sdb, shardId) {
+ // Check that change stream explain command is recorded.
+ let aggregateCmd = {
+ aggregate: "coll",
+ cursor: {},
+ pipeline: [{$changeStream: {}}],
+ $_passthroughToShard: {shard: shardId},
+ };
+
+ assert.commandWorked(sdb.runCommand({explain: aggregateCmd, verbosity: "executionStats"}));
+
+ let queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "coll",
+ numExecs: 1,
+ numDocsReturned: 0
+ });
+
+ assert(queryStatsEntry.key.hasOwnProperty("explain"));
+ jsTestLog("passthroughToShard");
+ jsTestLog(queryStatsEntry);
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Check creation of change stream cursor with $_passthroughToShard is recorded.
+ let resp = sdb.runCommand(aggregateCmd);
+ assert.commandWorked(resp);
+
+ let cursor = new DBCommandCursor(sdb, resp);
+
+ let numExecs = 1;
+ let numDocsReturned = 0;
+
+ queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "coll",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Insert document into collection on the shard the change stream was created on (negative id
+ // value), and make sure retrieving the information updates the query stats entry.
+ assert.commandWorked(sdb.coll.insertOne({location: 1, _id: -3}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(cursor);
+ numDocsReturned++;
+
+ queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "coll",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Close cursor and check that it updates query stats entry.
+ cursor.close();
+ numExecs++;
+ queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "coll",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Closing the cursor should result in 0 ms for the operation.
+ assert.eq(queryStatsEntry.metrics.lastExecutionMicros, 0);
+}
+
+function testDatabaseChangeStream(sdb, shardId) {
+ // Check creation of change stream cursor with $_passthroughToShard for whole database is
+ // recorded.
+ let aggregateCmd = {
+ aggregate: 1,
+ cursor: {},
+ pipeline: [{$changeStream: {}}],
+ $_passthroughToShard: {shard: shardId}
+ };
+
+ let resp = sdb.runCommand(aggregateCmd);
+ assert.commandWorked(resp);
+
+ let cursor = new DBCommandCursor(sdb, resp);
+
+ // Check creation of change stream cursor is recorded.
+ let numExecs = 1;
+ let numDocsReturned = 0;
+
+ let queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Insert document into collection on the shard the change stream is watching (negative id
+ // value), and make sure retrieving the information updates the query stats entry.
+ assert.commandWorked(sdb.coll.insertOne({location: 3, _id: -5}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(cursor);
+ numDocsReturned++;
+
+ queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Insert document into different collection on the shard the change stream is watching
+ // (negative id value), and make sure retrieving the information updates the query stats entry.
+ assert.commandWorked(sdb.coll2.insertOne({location: 4, _id: -10}));
+ numExecs += getNumberOfGetMoresUntilNextDocForChangeStream(cursor);
+ numDocsReturned++;
+
+ queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Close cursor and check that it increases numExecs.
+ cursor.close();
+ numExecs++;
+
+ queryStatsEntry = getLatestQueryStatsEntry(sdb);
+ checkChangeStreamEntry({
+ queryStatsEntry: queryStatsEntry,
+ db: sdb,
+ collectionName: "$cmd.aggregate",
+ numExecs: numExecs,
+ numDocsReturned: numDocsReturned
+ });
+ assert(queryStatsEntry.key.hasOwnProperty("$_passthroughToShard"));
+ assert(queryStatsEntry.key.$_passthroughToShard.hasOwnProperty("shard"));
+
+ // Closing the cursor should result in 0 ms for the operation.
+ assert.eq(queryStatsEntry.metrics.lastExecutionMicros, 0);
+}
+
+function runTest(sdb, shardId) {
+ testCollectionChangeStream(sdb, shardId);
+ testDatabaseChangeStream(sdb, shardId);
+}
+
+function setupShardedCluster() {
+ const st = new ShardingTest({
+ shards: 2,
+ mongos: 1,
+ config: 1,
+ rs: {nodes: 1, setParameter: {writePeriodicNoops: false}},
+ other: {
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ }
+ }
+ });
+
+ const sdb = st.s0.getDB(dbName);
+ assert.commandWorked(sdb.dropDatabase());
+
+ sdb.setProfilingLevel(0, -1);
+ st.shard0.getDB(dbName).setProfilingLevel(0, -1);
+
+ // Shard the relevant collections.
+ assert.commandWorked(st.s.adminCommand({enableSharding: dbName, primaryShard: st.shard0.name}));
+ // Shard the collection on {_id: 1}, split at {_id: 0} and move the empty upper chunk to
+ // shard1.
+ st.shardColl("coll", {_id: 1}, {_id: 0}, {_id: 0}, dbName);
+ st.shardColl("coll2", {_id: 1}, {_id: 0}, {_id: 0}, dbName);
+
+ // Returns shardID of shard containing the negative _id values.
+ const shardId = st.shard0.shardName;
+ return [sdb, st, shardId];
+}
+
+{
+ let [sdb, st, shardId] = setupShardedCluster();
+ st.shard0.getDB("admin").setLogLevel(3, "queryStats");
+ runTest(sdb, shardId);
+ st.stop();
+}
diff --git a/jstests/noPassthrough/queryStats/query_stats_collectionType.js b/jstests/noPassthrough/queryStats/query_stats_collectionType.js
new file mode 100644
index 00000000000..77428f9dc29
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_collectionType.js
@@ -0,0 +1,208 @@
+/**
+ * Test that collectionType is returned properly in $queryStats. Checks for collection types
+ * "collection", "view", "timeseries", "nonExistent", and "virtual". Type "changeStream" is covered
+ * in query_stats_changeStreams.js.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js");
+(function() {
+"use strict";
+
+function runTest(conn) {
+ const testDB = conn.getDB('test');
+
+ // We create one collection for each corresponding type reported by query stats.
+ assert.commandWorked(testDB.createCollection(jsTestName() + "_collection"));
+ assert.commandWorked(testDB.createView(
+ jsTestName() + "_view", jsTestName() + "_collection", [{$match: {v: {$gt: 42}}}]));
+ assert.commandWorked(
+ testDB.createCollection(jsTestName() + "_timeseries", {timeseries: {timeField: "time"}}));
+ // We create an additional view over the existing view to test full view resolution. We use
+ // the $setWindowFields stage since it desugars into multiple stages, to make sure the query
+ // shape produced is restricted to the user-provided query.
+ assert.commandWorked(testDB.createView(jsTestName() + "_viewOverView", jsTestName() + "_view", [
+ {
+ $setWindowFields: {
+ partitionBy: "$x",
+ sortBy: {v: 1},
+ output: {sum: {$sum: "$y", window: {documents: ["unbounded", "current"]}}}
+ }
+ },
+ {$project: {_id: 0, v: 1, x: 1, y: "$output.sum"}}
+ ]));
+
+ // Next we run queries over each of the collection types to generate query stats.
+
+ // Base _collection has a few simple documents.
+ var coll = testDB[jsTestName() + "_collection"];
+ coll.insert({v: 1});
+ coll.insert({v: 2});
+ coll.insert({v: 3});
+ coll.find({v: 3}).toArray();
+ coll.aggregate([]).toArray();
+
+ // View _view is over _collection.
+ coll = testDB[jsTestName() + "_view"];
+ coll.find({v: 5}).toArray();
+ coll.aggregate([{$match: {v: {$lt: 99}}}]).toArray();
+
+ // View _viewOverView is over _view.
+ coll = testDB[jsTestName() + "_viewOverView"];
+ // Run an empty find to make sure filter in query stats key is recorded as empty.
+ coll.find({}).toArray();
+ // We test with $densify since it desugars into multiple stages, again to ensure the query
+ // shape calculated is restricted to the user-provided query. (In this case, the desugared
+ // $densify is acceptable, as long as the view pipeline is ignored).
+ coll.aggregate([{$densify: {field: "y", range: {step: 10, bounds: "full"}}}]).toArray();
+
+ // Timeseries collection _timeseries.
+ coll = testDB[jsTestName() + "_timeseries"];
+ coll.insert({v: 1, time: ISODate("2021-05-18T00:00:00.000Z")});
+ coll.insert({v: 2, time: ISODate("2021-05-18T01:00:00.000Z")});
+ coll.insert({v: 3, time: ISODate("2021-05-18T02:00:00.000Z")});
+ coll.find({v: 6}).toArray();
+ // Run an empty aggregate to ensure pipeline in query stats key is recorded as empty.
+ coll.aggregate().toArray();
+
+ // QueryStats should still be collected for queries run on nonexistent collections.
+ assert.commandWorked(testDB.runCommand({find: jsTestName() + "_nonExistent", filter: {v: 6}}));
+ assert.commandWorked(
+ testDB.runCommand({aggregate: jsTestName() + "_nonExistent", pipeline: [], cursor: {}}));
+
+ // Verify that we have two query stats entries for the collection type. This assumes we have
+ // executed one find and one agg query for the given collection type.
+ function verifyQueryStatsForCollectionType(
+ collectionType, collectionName = jsTestName() + "_" + collectionType) {
+ const queryStats = getQueryStats(conn, {
+ extraMatch:
+ {"key.collectionType": collectionType, "key.queryShape.cmdNs.coll": collectionName}
+ });
+ // We should see one entry for find() and one for aggregate()
+ // for each collection type. The queries account for the fact
+ // that find() queries over views are rewritten to
+ // aggregate(). Ie, the query shapes are different because the
+ // queries are different.
+ assert.eq(2, queryStats.length, "Expected result for collection type " + collectionType);
+ }
+
+ verifyQueryStatsForCollectionType("collection");
+ verifyQueryStatsForCollectionType("view");
+ verifyQueryStatsForCollectionType("view", jsTestName() + "_viewOverView");
+ verifyQueryStatsForCollectionType("timeseries");
+ verifyQueryStatsForCollectionType("nonExistent");
+
+ // Run commands that should be tracked as "virtual" collection type.
+ assert.commandWorked(
+ testDB.adminCommand({aggregate: 1, pipeline: [{$currentOp: {}}], cursor: {}}));
+ assert.commandWorked(testDB.adminCommand(
+ {aggregate: 1, pipeline: [{$documents: [{a: 1}, {a: 4}]}], cursor: {}}));
+ assert.commandWorked(
+ testDB.adminCommand({aggregate: 1, pipeline: [{$listLocalSessions: {}}], cursor: {}}));
+
+ // Verify the queries on "virtual" collection types were tracked appropriately. This includes
+ // the 3 queries directly run above, in addition to 1 entry for the $queryStats aggregations
+ // run for the test.
+ let queryStats = getQueryStats(conn, {extraMatch: {"key.collectionType": "virtual"}});
+ assert.eq(4, queryStats.length);
+
+ // Verify that, for views, we capture the original query before it's rewritten. The view would
+ // include a $match stage with $gt predicate on 'v'. The viewOnView would include the same
+ // $match stage, the $setWindowFields stage, and the $project stage. The timeseries would
+ // include the bucket unpacking stages.
+ const findOnViewShape = getQueryStats(conn, {
+ extraMatch: {
+ "key.collectionType": "view",
+ "key.queryShape.command": "find",
+ "key.queryShape.cmdNs.coll": jsTestName() + "_view"
+ }
+ })[0]
+ .key.queryShape;
+ assert.eq(findOnViewShape.filter, {"v": {"$eq": "?number"}});
+
+ const aggOnViewShape = getQueryStats(conn, {
+ extraMatch: {
+ "key.collectionType": "view",
+ "key.queryShape.command": "aggregate",
+ "key.queryShape.cmdNs.coll": jsTestName() + "_view"
+ }
+ })[0]
+ .key.queryShape;
+ assert.eq(aggOnViewShape.pipeline, [{"$match": {"v": {"$lt": "?number"}}}]);
+
+ const findOnViewOverViewShape =
+ getQueryStats(conn, {
+ extraMatch: {
+ "key.collectionType": "view",
+ "key.queryShape.command": "find",
+ "key.queryShape.cmdNs.coll": jsTestName() + "_viewOverView"
+ }
+ })[0]
+ .key.queryShape;
+ assert.eq(findOnViewOverViewShape.filter, {});
+
+ const aggOnViewOverViewShape =
+ getQueryStats(conn, {
+ extraMatch: {
+ "key.collectionType": "view",
+ "key.queryShape.command": "aggregate",
+ "key.queryShape.cmdNs.coll": jsTestName() + "_viewOverView"
+ }
+ })[0]
+ .key.queryShape;
+ assert.eq(aggOnViewOverViewShape.pipeline, [
+ {"$sort": {"y": 1}},
+ {
+ "$_internalDensify": {
+ "field": "y",
+ "partitionByFields": [],
+ "range": {"step": "?number", "bounds": "full"}
+ }
+ }
+ ]);
+
+ const findOnTimeseriesShape = getQueryStats(conn, {
+ extraMatch: {
+ "key.collectionType": "timeseries",
+ "key.queryShape.command": "find",
+ "key.queryShape.cmdNs.coll": jsTestName() + "_timeseries"
+ }
+ })[0]
+ .key.queryShape;
+ assert.eq(findOnTimeseriesShape.filter, {"v": {"$eq": "?number"}});
+
+ const aggOnTimeseriesShape = getQueryStats(conn, {
+ extraMatch: {
+ "key.collectionType": "timeseries",
+ "key.queryShape.command": "aggregate",
+ "key.queryShape.cmdNs.coll": jsTestName() + "_timeseries"
+ }
+ })[0]
+ .key.queryShape;
+ assert.eq(aggOnTimeseriesShape.pipeline, []);
+}
+
+const conn = MongoRunner.runMongod({
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+});
+runTest(conn);
+MongoRunner.stopMongod(conn);
+
+// TODO Implement this in SERVER-76263.
+if (false) {
+ const st = new ShardingTest({
+ mongos: 1,
+ shards: 1,
+ config: 1,
+ rs: {nodes: 1},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsSamplingRate: -1,
+ }
+ },
+ });
+ runTest(st.s);
+ st.stop();
+}
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_disable_after_initial_request.js b/jstests/noPassthrough/queryStats/query_stats_disable_after_initial_request.js
new file mode 100644
index 00000000000..e088c045eef
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_disable_after_initial_request.js
@@ -0,0 +1,98 @@
+/**
+ * Tests that QueryStats metrics aren't collected if the feature is enabled initially but is
+ * disabled before the lifetime of the request is complete.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For getQueryStatsFindCmd.
+(function() {
+"use strict";
+
+// Test that no QueryStats entry is written when (1) dispatching an initial find query, (2)
+// disabling QueryStats, then (3) completing the command. Below, we run variations of this test
+// with combinations of different strategies to disable QueryStats and to end the command.
+function testStatsAreNotCollectedWhenDisabledBeforeCommandCompletion(
+ {conn, coll, disableQueryStatsFn, endCommandFn, enableQueryStatsFn}) {
+ // Issue a find commannd with a batchSize of 1 so that the query is not exhausted.
+ const cursor = coll.find({foo: 1}).batchSize(1);
+ // Must run .next() to make sure the initial request is executed now.
+ cursor.next();
+
+ // Disable QueryStats, then end the command,which triggers the path to writeQueryStats.
+ disableQueryStatsFn();
+ endCommandFn(cursor);
+
+ // Must re-enable QueryStats in order to check via $queryStats that nothing was recorded.
+ enableQueryStatsFn();
+ const res = getQueryStatsFindCmd(conn, {collName: coll.getName()});
+ assert.eq(res.length, 0, res);
+}
+
+// Turn on the collecting of QueryStats metrics.
+let options = {setParameter: {internalQueryStatsRateLimit: -1}};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+const bulk = coll.initializeUnorderedBulkOp();
+const numDocs = 100;
+for (let i = 0; i < numDocs / 2; ++i) {
+ bulk.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+ bulk.insert({foo: 1, bar: Math.floor(Math.random() * -2)});
+}
+assert.commandWorked(bulk.execute());
+
+function setQueryStatsCacheSize(size) {
+ assert.commandWorked(testDB.adminCommand({setParameter: 1, internalQueryStatsCacheSize: size}));
+}
+
+function setFCV(newFCV) {
+ assert.commandWorked(testDB.adminCommand({setFeatureCompatibilityVersion: newFCV}));
+}
+
+// Tests the scenario of disabling QueryStats by setting internalQueryStatsCacheSize to
+// 0 and ending the command by running it to completion.
+testStatsAreNotCollectedWhenDisabledBeforeCommandCompletion({
+ conn: testDB,
+ coll,
+ disableQueryStatsFn: () => setQueryStatsCacheSize("0MB"),
+ endCommandFn: (cursor) => cursor.itcount(),
+ enableQueryStatsFn: () => setQueryStatsCacheSize("10MB")
+});
+
+// Tests the scenario of disabling QueryStats by setting internalQueryStatsCacheSize to
+// 0 and ending the command by killing the cursor.
+testStatsAreNotCollectedWhenDisabledBeforeCommandCompletion({
+ conn: testDB,
+ coll,
+ disableQueryStatsFn: () => setQueryStatsCacheSize("0MB"),
+ endCommandFn: (cursor) => assert.commandWorked(
+ testDB.runCommand({killCursors: coll.getName(), cursors: [cursor.getId()]})),
+ enableQueryStatsFn: () => setQueryStatsCacheSize("10MB")
+});
+
+jsTestLog(lastLTSFCV);
+// Tests the scenario of disabling query stats by downgrading the FCV and ending the command by
+// running it to completion.
+testStatsAreNotCollectedWhenDisabledBeforeCommandCompletion({
+ conn: testDB,
+ coll,
+ disableQueryStatsFn: () => setFCV(lastLTSFCV),
+ endCommandFn: (cursor) => cursor.itcount(),
+ enableQueryStatsFn: () => setFCV(binVersionToFCV("latest"))
+});
+
+// Tests the scenario of disabling query stats by downgrading the FCV and ending the command by
+// killing the cursor.
+testStatsAreNotCollectedWhenDisabledBeforeCommandCompletion({
+ conn: testDB,
+ coll,
+ disableQueryStatsFn: () => setFCV(lastLTSFCV),
+ endCommandFn: (cursor) => assert.commandWorked(
+ testDB.runCommand({killCursors: coll.getName(), cursors: [cursor.getId()]})),
+ enableQueryStatsFn: () => setFCV(binVersionToFCV("latest"))
+});
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_explain_cmd_agg.js b/jstests/noPassthrough/queryStats/query_stats_explain_cmd_agg.js
new file mode 100644
index 00000000000..2a12b85f274
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_explain_cmd_agg.js
@@ -0,0 +1,37 @@
+/**
+ * This test confirms that the correct verbosity levels are stored in the query stats key for
+ * explain commands on an agg query.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js");
+
+const testColl = jsTestName();
+
+const testPipeline = [{"$match": {"bar": {"$gte": 1}}}, {"$count": "bars"}];
+
+function assertVerbosityField(coll, pipeline, verbosity) {
+ assert.commandWorked(coll.explain(verbosity).aggregate(pipeline));
+ const entry = getLatestQueryStatsEntry(coll.getDB().getMongo(), {collName: coll.getName()});
+ assert.eq(getValueAtPath(entry, "key.explain"), verbosity, tojson(entry));
+ assert.eq(getValueAtPath(entry, "metrics.execCount"), 1, tojson(entry));
+ // TODO SERVER-89439: Uncomment this block and perhaps add more assertions.
+ // if (!FixtureHelpers.isSharded(coll)) {
+ // TODO SERVER-89053: Remove conditional statement to assert in sharded tests.
+ // assert.gt(getValueAtPath(entry, "metrics.lastExecutionMicros"), 1, tojson(entry));
+ // }
+}
+
+withQueryStatsEnabled(testColl, (coll) => {
+ // Insert documents to ensure we raise 'execCount'.
+ const bulk = coll.initializeUnorderedBulkOp();
+ const numDocs = 100;
+ for (let i = 0; i < numDocs / 2; ++i) {
+ bulk.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+ bulk.insert({foo: 1, bar: Math.floor(Math.random() * -2)});
+ }
+ assert.commandWorked(bulk.execute());
+
+ assertVerbosityField(coll, testPipeline, 'queryPlanner');
+ assertVerbosityField(coll, testPipeline, 'allPlansExecution');
+ assertVerbosityField(coll, testPipeline, 'executionStats');
+});
diff --git a/jstests/noPassthrough/queryStats/query_stats_expressions.js b/jstests/noPassthrough/queryStats/query_stats_expressions.js
new file mode 100644
index 00000000000..133582cb715
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_expressions.js
@@ -0,0 +1,76 @@
+/**
+ * Test that queryStats works properly for a find command that uses agg expressions and produces the
+ * proper query shape without issues during re-parsing.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For getQueryStats and resetQueryStatsStore.
+
+(function() {
+"use strict";
+
+// Turn on the collecting of queryStats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+const bulk = coll.initializeUnorderedBulkOp();
+const numDocs = 100;
+for (let i = 0; i < numDocs / 2; ++i) {
+ bulk.insert({foo: i, bar: i, applyDiscount: true, word: "asdf"});
+ bulk.insert({foo: i, bar: i, applyDiscount: false, word: "ghjk"});
+}
+assert.commandWorked(bulk.execute());
+coll.createIndex({foo: 1});
+
+// Tests that $meta is re-parsed correctly by ensuring the metaDataKeyword is not serialized as
+// string literal.
+{
+ resetQueryStatsStore(conn, "1MB");
+ coll.find({$expr: {idxKey: {$meta: "indexKey"}}}).itcount();
+ let queryStats = getQueryStats(testDB);
+ assert.eq({"$expr": {"idxKey": {"$meta": "indexKey"}}}, queryStats[0].key.queryShape.filter);
+}
+
+// Tests that $regexMatch is re-parsed correctly. The parser does not check that regex is a valid
+// regex pattern, so regular serialization is okay.
+{
+ resetQueryStatsStore(conn, "1MB");
+ coll.find({$expr: {result: {$regexMatch: {input: "$word", regex: "^a"}}}}).itcount();
+ let queryStats = getQueryStats(testDB);
+ assert.eq({"$expr": {"result": {"$regexMatch": {"input": "$word", "regex": "?string"}}}},
+ queryStats[0].key.queryShape.filter);
+}
+
+// Tests that $let is re-parsed correctly, by ensuring the variables are not serialized as string
+// literals.
+{
+ resetQueryStatsStore(conn, "1MB");
+ coll.find({$expr: {$let: {
+ vars: {
+ total: { $add: [ '$foo', '$bar' ] },
+ discounted: { $cond: { if: '$applyDiscount', then: 0.9, else: 1 } }
+ },
+ in: { $multiply: [ "$$total", "$$discounted" ] }
+ }}}).itcount();
+ let queryStats = getQueryStats(testDB);
+ assert.eq({
+ "$expr": {
+ "$let": {
+ "vars": {
+ "total": {"$add": ["$foo", "$bar"]},
+ "discounted": {"$cond": ["$applyDiscount", "?number", "?number"]}
+ },
+ "in": {"$multiply": ["$$total", "$$discounted"]}
+ }
+ }
+ },
+ queryStats[0].key.queryShape.filter);
+}
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_feature_flag.js b/jstests/noPassthrough/queryStats/query_stats_feature_flag.js
new file mode 100644
index 00000000000..5580c5ec29d
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_feature_flag.js
@@ -0,0 +1,31 @@
+/**
+ * Test that calls to read from query stats store fail when feature flag is turned off.
+ */
+load('jstests/libs/analyze_plan.js');
+load("jstests/libs/feature_flag_util.js");
+
+(function() {
+"use strict";
+
+// This test specifically tests error handling when the feature flag is not on.
+// TODO SERVER-65800 this test can be removed when the feature flag is removed.
+const conn = MongoRunner.runMongod({
+ setParameter: {featureFlagQueryStats: false},
+});
+const testDB = conn.getDB('test');
+
+// Pipeline to read queryStats store should fail without feature flag turned on.
+assert.commandFailedWithCode(
+ testDB.adminCommand({aggregate: 1, pipeline: [{$queryStats: {}}], cursor: {}}),
+ ErrorCodes.QueryFeatureNotAllowed);
+
+// Pipeline, with a filter, to read queryStats store fails without feature flag turned on.
+assert.commandFailedWithCode(testDB.adminCommand({
+ aggregate: 1,
+ pipeline: [{$queryStats: {}}, {$match: {"key.queryShape.find": {$eq: "###"}}}],
+ cursor: {}
+}),
+ ErrorCodes.QueryFeatureNotAllowed);
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_find_cmd_collect_on_mongos.js b/jstests/noPassthrough/queryStats/query_stats_find_cmd_collect_on_mongos.js
new file mode 100644
index 00000000000..a1f3bc5d59e
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_find_cmd_collect_on_mongos.js
@@ -0,0 +1,126 @@
+/**
+ * Test that mongos is collecting query stats metrics for find queries.
+ * @tags: [requires_fcv_60]
+ */
+
+load('jstests/libs/query_stats_utils.js');
+
+(function() {
+"use strict";
+
+const st = new ShardingTest({
+ mongos: 1,
+ shards: 1,
+ config: 1,
+ rs: {nodes: 1},
+ mongosOptions: {
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+ },
+});
+const mongos = st.s;
+const db = mongos.getDB("test");
+const coll = db.coll;
+coll.insert({v: 1});
+coll.insert({v: 4});
+
+// Assert that, for find queries, no query stats results are written until a cursor has reached
+// exhaustion; ensure accurate results once they're written.
+{
+ const queryStatsKey = {
+ queryShape: {
+ cmdNs: {db: "test", coll: "coll"},
+ command: "find",
+ filter: {$and: [{v: {$gt: "?number"}}, {v: {$lt: "?number"}}]},
+ },
+ readConcern: {level: "local", provenance: "implicitDefault"},
+ batchSize: "?number",
+ client: {application: {name: "MongoDB Shell"}}
+ };
+
+ const cursor = coll.find({v: {$gt: 0, $lt: 5}}).batchSize(1); // returns 1 doc
+
+ // Since the cursor hasn't been exhausted yet, ensure no query stats results have been written
+ // yet.
+ let queryStats = getQueryStats(db);
+ assert.eq(0, queryStats.length, queryStats);
+
+ // Run a getMore to exhaust the cursor, then ensure query stats results have been written
+ // accurately. batchSize must be 2 so the cursor recognizes exhaustion.
+ assert.commandWorked(db.runCommand({
+ getMore: cursor.getId(),
+ collection: coll.getName(),
+ batchSize: 2
+ })); // returns 1 doc, exhausts the cursor
+ queryStats = getQueryStatsFindCmd(db);
+ assert.eq(1, queryStats.length, queryStats);
+ assertExpectedResults(queryStats[0],
+ queryStatsKey,
+ /* expectedExecCount */ 1,
+ /* expectedDocsReturnedSum */ 2,
+ /* expectedDocsReturnedMax */ 2,
+ /* expectedDocsReturnedMin */ 2,
+ /* expectedDocsReturnedSumOfSq */ NumberDecimal("4"),
+ /* getMores */ true);
+
+ // Run more queries (to exhaustion) with the same query shape, and ensure query stats results
+ // are accurate.
+ coll.find({v: {$gt: 2, $lt: 3}}).batchSize(10).toArray(); // returns 0 docs
+ coll.find({v: {$gt: 0, $lt: 1}}).batchSize(10).toArray(); // returns 0 docs
+ coll.find({v: {$gt: 0, $lt: 2}}).batchSize(10).toArray(); // return 1 doc
+ queryStats = getQueryStatsFindCmd(db);
+ assert.eq(1, queryStats.length, queryStats);
+ assertExpectedResults(queryStats[0],
+ queryStatsKey,
+ /* expectedExecCount */ 4,
+ /* expectedDocsReturnedSum */ 3,
+ /* expectedDocsReturnedMax */ 2,
+ /* expectedDocsReturnedMin */ 0,
+ /* expectedDocsReturnedSumOfSq */ NumberDecimal("5"),
+ /* getMores */ true);
+}
+
+// Assert on batchSize-limited find queries that killCursors will write metrics with partial results
+// to the query stats store.
+{
+ const coll2 = db.coll2;
+ coll2.insert({v: 1});
+ coll2.insert({v: 4});
+ const queryStatsKey = {
+ queryShape: {
+ cmdNs: {db: "test", coll: "coll2"},
+ command: "find",
+ filter: {$and: [{v: {$gt: "?number"}}, {v: {$lt: "?number"}}]},
+ },
+ readConcern: {level: "local", provenance: "implicitDefault"},
+ batchSize: "?number",
+ client: {application: {name: "MongoDB Shell"}}
+ };
+
+ const cursor1 = coll2.find({v: {$gt: 0, $lt: 5}}).batchSize(1); // returns 1 doc
+ const cursor2 = coll2.find({v: {$gt: 0, $lt: 2}}).batchSize(1); // returns 1 doc
+
+ assert.commandWorked(
+ db.runCommand({killCursors: coll2.getName(), cursors: [cursor1.getId(), cursor2.getId()]}));
+ const queryStats = getLatestQueryStatsEntry(db);
+ assertExpectedResults(queryStats,
+ queryStatsKey,
+ /* expectedExecCount */ 2,
+ /* expectedDocsReturnedSum */ 2,
+ /* expectedDocsReturnedMax */ 1,
+ /* expectedDocsReturnedMin */ 1,
+ /* expectedDocsReturnedSumOfSq */ NumberDecimal("2"),
+ /* getMores */ false);
+}
+
+// SERVER-83964 Test that query stats are collected if the database doesn't exist.
+{
+ const nonExistentDB = db.getSiblingDB("newDB");
+ assert.eq(null, nonExistentDB.anything.findOne());
+ const entry = getLatestQueryStatsEntry(db, {collName: "anything"});
+ assert.neq(null, entry);
+}
+
+st.stop();
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_find_key.js b/jstests/noPassthrough/queryStats/query_stats_find_key.js
new file mode 100644
index 00000000000..2c1d65f5bb5
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_find_key.js
@@ -0,0 +1,113 @@
+/**
+ * This test confirms that query stats store key fields for a find command are properly nested and
+ * none are missing.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For runCommandAndValidateQueryStats and
+ // withQueryStatsEnabled and getLatestQueryStatsEntry
+(function() {
+"use strict";
+
+const collName = jsTestName();
+
+const findCommandObj = {
+ find: collName,
+ filter: {v: {$eq: 2}},
+ oplogReplay: true,
+ comment: "this is a test!!",
+ min: {"v": 0},
+ max: {"v": 4},
+ hint: {"v": 1},
+ sort: {a: -1},
+ returnKey: false,
+ noCursorTimeout: true,
+ showRecordId: false,
+ tailable: false,
+ awaitData: false,
+ allowPartialResults: true,
+ skip: 1,
+ limit: 2,
+ maxTimeMS: 50 * 1000,
+ collation: {locale: "en_US", strength: 2},
+ allowDiskUse: true,
+ readConcern: {level: "local"},
+ batchSize: 2,
+ singleBatch: true,
+ let : {},
+ projection: {_id: 0},
+ apiDeprecationErrors: false,
+ apiVersion: "1",
+ apiStrict: false,
+};
+
+const queryShapeFindFields = [
+ "cmdNs",
+ "command",
+ "filter",
+ "sort",
+ "projection",
+ "skip",
+ "limit",
+ "singleBatch",
+ "max",
+ "min",
+ "returnKey",
+ "showRecordId",
+ "tailable",
+ "oplogReplay",
+ "awaitData",
+ "collation",
+ "allowDiskUse",
+ "let"
+];
+
+// The outer fields not nested inside queryShape.
+const findKeyFields = [
+ "queryShape",
+ "batchSize",
+ "comment",
+ "maxTimeMS",
+ "noCursorTimeout",
+ "readConcern",
+ "allowPartialResults",
+ "apiDeprecationErrors",
+ "apiVersion",
+ "apiStrict",
+ "collectionType",
+ "client",
+ "hint"
+];
+
+/**
+ * Regression test for SERVER-85532: $hint syntax will not be validated if the collection does not
+ * exist. Make sure that query stats can still handle an invalid hint. See SERVER-85500.
+ *
+ * @param testDB
+ */
+function validateInvalidHint(testDB) {
+ const collName = "invalid_hint_coll";
+ var coll = testDB[collName];
+ coll.drop();
+ // $hint is supposed to be a string or object, but this works:
+ assert.commandWorked(testDB.runCommand({
+ find: collName,
+ hint: {$hint: -1.0},
+ }));
+ const entry = getLatestQueryStatsEntry(testDB.getMongo(), {collName: collName});
+ assert.eq(entry.key.hint, {$hint: "?number"});
+}
+
+withQueryStatsEnabled(collName, (coll) => {
+ // Have to create an index for hint not to fail.
+ assert.commandWorked(coll.createIndex({v: 1}));
+
+ runCommandAndValidateQueryStats({
+ coll: coll,
+ commandName: "find",
+ commandObj: findCommandObj,
+ shapeFields: queryShapeFindFields,
+ keyFields: findKeyFields,
+ });
+ validateInvalidHint(coll);
+});
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_key_hash.js b/jstests/noPassthrough/queryStats/query_stats_key_hash.js
new file mode 100644
index 00000000000..0eaacf9fecf
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_key_hash.js
@@ -0,0 +1,98 @@
+/**
+ * This test confirms that the hash generated alongside query stats entries is unique and stable
+ * across restarts.
+ * @tags: [requires_fcv_60]
+ */
+(function() {
+'use strict';
+
+load("jstests/libs/query_stats_utils.js"); // For getQueryStatsFindCmd and getQueryStatsKeyHashes.
+
+const replTest = new ReplSetTest({name: 'queryStatsKeyHashTest', nodes: 2});
+
+// Turn on the collecting of query stats metrics.
+replTest.startSet({setParameter: {internalQueryStatsRateLimit: -1}});
+replTest.initiate();
+
+const conn = replTest.getPrimary();
+const collName = jsTestName();
+const coll = conn.getDB("test")[collName];
+coll.drop();
+coll.insert({x: 5});
+
+let totalShapes = 0;
+
+// Simple query, different field names.
+coll.find({x: 5}).toArray();
+++totalShapes;
+coll.find({y: 5}).toArray();
+++totalShapes;
+
+// Simple $regex query, sort, and limit.
+coll.find({x: {$regex: ".*"}}).sort({y: -1}).limit(10).toArray();
+++totalShapes;
+coll.find({x: {$regex: "."}}).sort({y: -1}).limit(10).toArray();
+// Don't increment 'totalShapes' - different $regex value still means the same shape.
+coll.find({x: {$regex: "."}}).sort({y: -1}).limit(10).toArray();
+
+// Simple query with projection and limit.
+coll.find({x: {$regex: ".*"}}, {_id: 0, x: "$$ROOT", y: {$bsonSize: ["$$ROOT"]}})
+ .limit(5)
+ .toArray();
+++totalShapes;
+coll.find({x: {$regex: ".*"}}, {_id: 0, x: "$$ROOT", y: {$bsonSize: ["$$ROOT"]}})
+ .limit(10)
+ .toArray();
+// Don't increment 'totalShapes' - different limit value still means the same shape.
+coll.find({x: {$regex: ".*"}}, {_id: false, x: "$$ROOT", y: {$bsonSize: ["$$ROOT"]}})
+ .limit(5)
+ .toArray();
+// Don't increment 'totalShapes' - 'false' projection is the same as '0'.
+coll.find({x: {$regex: ".*"}}, {_id: 0, x: "$$ROOT", y: {$bsonSize: ["$$CURRENT"]}})
+ .limit(5)
+ .toArray();
+++totalShapes; // different $bsonSize argument
+coll.find({x: {$regex: ".*"}}, {x: "$$ROOT", y: {$bsonSize: ["$$ROOT"]}, _id: true})
+ .limit(5)
+ .toArray();
+++totalShapes; // explicit '_id' inclusion projection
+coll.find({x: {$regex: ".*"}}, {x: "$$ROOT", y: {$bsonSize: ["$$ROOT"]}}).limit(5).toArray();
+// Don't increment 'totalShapes' - '_id' inclusion projection is implicit.
+
+// Different command options.
+const filter = {
+ x: 5
+};
+const maxTimeMS = 123;
+const batchSize = 10;
+const readPref = "primary";
+coll.find(filter).maxTimeMS(maxTimeMS).batchSize(batchSize).readPref(readPref).toArray();
+++totalShapes;
+// Vary values for the options specified - shape should not change for 'maxTimeMS' and
+// 'batchSize', but should for 'readPreference'.
+coll.find(filter).maxTimeMS(456).batchSize(batchSize).readPref(readPref).toArray();
+coll.find(filter).maxTimeMS(maxTimeMS).batchSize(20).readPref(readPref).toArray();
+coll.find(filter).maxTimeMS(maxTimeMS).batchSize(batchSize).readPref("nearest").toArray();
+++totalShapes;
+
+const untransformedEntries = getQueryStatsFindCmd(conn, {collName, transformIdentifiers: false});
+const untransformedKeyHashes = getQueryStatsKeyHashes(untransformedEntries);
+
+// Ensure that we got as many unique key hashes as we expect give the queries that we ran.
+assert.eq(untransformedKeyHashes.length, totalShapes, tojson(untransformedEntries));
+
+// We need to filter on the HMAC-ed collection name when we set {transformIdentifiers: true}.
+const transformedCollName = "h52faC1z+jJCQp/Hq008ffChPpnk/nhAgo70uX1FFFI=";
+const transformedEntries =
+ getQueryStatsFindCmd(conn, {collName: transformedCollName, transformIdentifiers: true});
+const transformedKeyHashes = getQueryStatsKeyHashes(transformedEntries);
+
+// We expect the hash to be derived from the untransformed shape, so the transformed $queryStats
+// call should produce the same hash values as the untransformed one.
+assert.sameMembers(untransformedKeyHashes,
+ transformedKeyHashes,
+ `untransformedEntries = ${tojson(untransformedEntries)}, transformedEntries = ${
+ tojson(transformedEntries)}`);
+
+replTest.stopSet();
+})();
diff --git a/jstests/noPassthrough/queryStats/query_stats_logging.js b/jstests/noPassthrough/queryStats/query_stats_logging.js
new file mode 100644
index 00000000000..c09f2948aa0
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_logging.js
@@ -0,0 +1,99 @@
+/**
+ * Test logging of $queryStats.
+ * @tags: [requires_fcv_60]
+ */
+
+(function() {
+"use strict";
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+function countMatching(arr, func) {
+ let count = 0;
+ let i = arr.length;
+ while (i--) {
+ if (func(arr[i])) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+function runQueryStatsAndVerifyLogs({pipeline, transformed, logLevel}) {
+ const conn = MongoRunner.runMongod(options);
+ const db = conn.getDB('test');
+ var coll = db[jsTestName()];
+ coll.drop();
+
+ // Set the logLevel.
+ assert.commandWorked(db.setLogLevel(logLevel, "queryStats"));
+
+ // Insert a document and run two find command with different shapes so that we have two entries
+ // in the queryStats store.
+ assert.commandWorked(coll.insert({foo: 1, bar: "hello"}));
+ assert.eq(coll.find({foo: 1}).itcount(), 1);
+ assert.eq(coll.find({bar: "hello"}).itcount(), 1);
+
+ // Run $queryStats with the specified pipeline.
+ let result = conn.adminCommand({aggregate: 1, pipeline: [pipeline], cursor: {}});
+ assert.commandWorked(result);
+
+ if (logLevel >= 1) {
+ // Checking that we log the invocation of $queryStats.
+ let spec = transformed
+ ? {"transformIdentifiers": {"algorithm": "hmac-sha-256", "hmacKey": "###"}}
+ : {};
+ assert(checkLog.checkContainsWithCountJson(
+ conn, 7808300, {"commandSpec": spec}, 1, null, true),
+ "failed to find log with id " + 7808300);
+ }
+
+ // We only log the output of $queryStats when invoked with transformation, and log level at
+ // least 3.
+ if (logLevel >= 3 && transformed) {
+ // Checking that we are logging both entries in the query stats store.
+ assert(checkLog.checkContainsWithCountJson(conn, 7808301, {}, 2, null, true),
+ "failed to find log with id " + 7808301);
+
+ // Checking that the output is what we expect.
+ const query = assert.commandWorked(db.adminCommand({getLog: "global"}));
+ assert(query.hasOwnProperty("log"), "no log field");
+ assert.eq(countMatching(query.log, function(v) {
+ const has = (s) => v.indexOf(s) !== -1;
+ return has("thisOutput") && has("key:") && has("queryShape") &&
+ (has("?number") || has("?string"));
+ }), 2);
+
+ // Checking that we log when we are done outputting the results of $queryStats.
+ assert(checkLog.checkContainsWithCountJson(conn, 7808302, {}, 1, null, true),
+ "failed to find log with id " + 7808302);
+ }
+
+ // At a log level of 0 we should not be seeing any of these logs.
+ if (logLevel == 0) {
+ assert(checkLog.checkContainsWithCountJson(conn, 7808300, {}, 0, null, true));
+ assert(checkLog.checkContainsWithCountJson(conn, 7808301, {}, 0, null, true));
+ assert(checkLog.checkContainsWithCountJson(conn, 7808302, {}, 0, null, true));
+ }
+
+ MongoRunner.stopMongod(conn);
+}
+
+// Testing logging with transformation.
+const hmacKey = "MjM0NTY3ODkxMDExMTIxMzE0MTUxNjE3MTgxOTIwMjE=";
+let pipeline = {
+ $queryStats: {transformIdentifiers: {algorithm: "hmac-sha-256", hmacKey: BinData(8, hmacKey)}}
+};
+runQueryStatsAndVerifyLogs({pipeline: pipeline, transformed: true, logLevel: 3});
+runQueryStatsAndVerifyLogs({pipeline: pipeline, transformed: true, logLevel: 1});
+runQueryStatsAndVerifyLogs({pipeline: pipeline, transformed: true, logLevel: 0});
+
+// Testing logging without transformation.
+pipeline = {
+ $queryStats: {}
+};
+runQueryStatsAndVerifyLogs({pipeline: pipeline, transformed: false, logLevel: 3});
+runQueryStatsAndVerifyLogs({pipeline: pipeline, transformed: false, logLevel: 1});
+runQueryStatsAndVerifyLogs({pipeline: pipeline, transformed: false, logLevel: 0});
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_metrics_across_getMore_calls.js b/jstests/noPassthrough/queryStats/query_stats_metrics_across_getMore_calls.js
new file mode 100644
index 00000000000..1bfb1c36ae5
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_metrics_across_getMore_calls.js
@@ -0,0 +1,173 @@
+/**
+ * Test that the queryStats metrics are aggregated properly by distinct query shape over getMore
+ * calls, for agg commands.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For verifyMetrics and getQueryStatsAggCmd.
+
+(function() {
+"use strict";
+
+// Turn on the collecting of queryStats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+// Bulk insert documents to reduces roundtrips and make timeout on a slow machine less likely.
+const bulk = coll.initializeUnorderedBulkOp();
+const numDocs = 100;
+for (let i = 0; i < numDocs / 2; ++i) {
+ bulk.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+ bulk.insert({foo: 1, bar: Math.floor(Math.random() * -2)});
+}
+assert.commandWorked(bulk.execute());
+// Assert that two queries with identical structures are represented by the same key.
+{
+ // Note: toArray() is necessary for the batchSize-limited query to run to cursor exhaustion
+ // (when it writes to the queryStats store).
+ coll.aggregate([{$match: {foo: 1}}], {cursor: {batchSize: 2}}).toArray();
+ coll.aggregate([{$match: {foo: 0}}], {cursor: {batchSize: 2}}).toArray();
+
+ // This command will return all queryStats store entires.
+ const queryStatsResults = getQueryStatsAggCmd(testDB);
+ // Assert there is only one entry.
+ assert.eq(queryStatsResults.length, 1, queryStatsResults);
+ const queryStatsEntry = queryStatsResults[0];
+ assert.eq(queryStatsEntry.key.queryShape.cmdNs.db, "test");
+ assert.eq(queryStatsEntry.key.queryShape.cmdNs.coll, jsTestName());
+ assert.eq(queryStatsEntry.key.client.application.name, "MongoDB Shell");
+
+ // Assert we update execution count for identically shaped queries.
+ assert.eq(queryStatsEntry.metrics.execCount, 2);
+
+ // Assert queryStats values are accurate for the two above queries.
+ assert.eq(queryStatsEntry.metrics.docsReturned.sum, numDocs);
+ assert.eq(queryStatsEntry.metrics.docsReturned.min, numDocs / 2);
+ assert.eq(queryStatsEntry.metrics.docsReturned.max, numDocs / 2);
+
+ verifyMetrics(queryStatsResults);
+}
+
+const fooEqBatchSize = 5;
+const fooNeBatchSize = 3;
+// Assert on batchSize-limited queries that killCursors will write metrics with partial results to
+// the queryStats store.
+{
+ let cursor1 = coll.find({foo: {$eq: 0}}).batchSize(fooEqBatchSize);
+ let cursor2 = coll.find({foo: {$ne: 0}}).batchSize(fooNeBatchSize);
+ // Issue one getMore for the first query, so 2 * fooEqBatchSize documents are returned total.
+ assert.commandWorked(testDB.runCommand(
+ {getMore: cursor1.getId(), collection: coll.getName(), batchSize: fooEqBatchSize}));
+
+ // Kill both cursors so the queryStats metrics are stored.
+ assert.commandWorked(testDB.runCommand(
+ {killCursors: coll.getName(), cursors: [cursor1.getId(), cursor2.getId()]}));
+
+ // This filters queryStats entires to just the ones entered when running above find queries.
+ const queryStatsResults = testDB.getSiblingDB("admin")
+ .aggregate([
+ {$queryStats: {}},
+ {$match: {"key.queryShape.filter.foo": {$exists: true}}},
+ {$sort: {key: 1}},
+ ])
+ .toArray();
+ assert.eq(queryStatsResults.length, 2, queryStatsResults);
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.db, "test");
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.coll, jsTestName());
+ assert.eq(queryStatsResults[0].key.client.application.name, "MongoDB Shell");
+ assert.eq(queryStatsResults[1].key.queryShape.cmdNs.db, "test");
+ assert.eq(queryStatsResults[1].key.queryShape.cmdNs.coll, jsTestName());
+ assert.eq(queryStatsResults[1].key.client.application.name, "MongoDB Shell");
+
+ assert.eq(queryStatsResults[0].metrics.execCount, 1);
+ assert.eq(queryStatsResults[1].metrics.execCount, 1);
+ assert.eq(queryStatsResults[0].metrics.docsReturned.sum, fooEqBatchSize * 2);
+ assert.eq(queryStatsResults[1].metrics.docsReturned.sum, fooNeBatchSize);
+
+ verifyMetrics(queryStatsResults);
+
+ const distributionFields = ['sum', 'max', 'min', 'sumOfSquares'];
+ for (const field of distributionFields) {
+ // If there are getMore calls, queryExecMicros should be greater than or equal to
+ // firstResponseExecMicros.
+ assert(bsonWoCompare(queryStatsResults[0].metrics.totalExecMicros[field],
+ queryStatsResults[0].metrics.firstResponseExecMicros[field]) > 0);
+
+ // If there is no getMore calls, firstResponseExecMicros and queryExecMicros should be
+ // equal.
+ assert.eq(queryStatsResults[1].metrics.totalExecMicros[field],
+ queryStatsResults[1].metrics.firstResponseExecMicros[field]);
+ }
+}
+
+// Assert that options such as limit/sort create different keys, and that repeating a query shape
+// ({foo: {$eq}}) aggregates metrics across executions.
+{
+ const query2Limit = 50;
+ coll.find({foo: {$eq: 0}}).batchSize(2).toArray();
+ coll.find({foo: {$eq: 1}}).limit(query2Limit).batchSize(2).toArray();
+ coll.find().sort({"foo": 1}).batchSize(2).toArray();
+ // This filters queryStats entires to just the ones entered when running above find queries.
+ let queryStatsResults =
+ testDB.getSiblingDB("admin")
+ .aggregate([{$queryStats: {}}, {$match: {"key.queryShape.command": "find"}}])
+ .toArray();
+ assert.eq(queryStatsResults.length, 4, queryStatsResults);
+
+ verifyMetrics(queryStatsResults);
+
+ // This filters to just the queryStats for query coll.find().sort({"foo": 1}).batchSize(2).
+ queryStatsResults =
+ testDB.getSiblingDB("admin")
+ .aggregate([{$queryStats: {}}, {$match: {"key.queryShape.sort.foo": 1}}])
+ .toArray();
+ assert.eq(queryStatsResults.length, 1, queryStatsResults);
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.db, "test");
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.coll, jsTestName());
+ assert.eq(queryStatsResults[0].key.client.application.name, "MongoDB Shell");
+ assert.eq(queryStatsResults[0].metrics.execCount, 1);
+ assert.eq(queryStatsResults[0].metrics.docsReturned.sum, numDocs);
+
+ // This filters to just the queryStats for query coll.find({foo: {$eq:
+ // 1}}).limit(query2Limit).batchSize(2).
+ queryStatsResults =
+ testDB.getSiblingDB("admin")
+ .aggregate([{$queryStats: {}}, {$match: {"key.queryShape.limit": '?number'}}])
+ .toArray();
+ assert.eq(queryStatsResults.length, 1, queryStatsResults);
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.db, "test");
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.coll, jsTestName());
+ assert.eq(queryStatsResults[0].key.client.application.name, "MongoDB Shell");
+ assert.eq(queryStatsResults[0].metrics.execCount, 1);
+ assert.eq(queryStatsResults[0].metrics.docsReturned.sum, query2Limit);
+
+ // This filters to just the queryStats for query coll.find({foo: {$eq: 0}}).batchSize(2).
+ queryStatsResults = testDB.getSiblingDB("admin")
+ .aggregate([
+ {$queryStats: {}},
+ {
+ $match: {
+ "key.queryShape.filter.foo": {$eq: {$eq: "?number"}},
+ "key.queryShape.limit": {$exists: false},
+ "key.queryShape.sort": {$exists: false}
+ }
+ }
+ ])
+ .toArray();
+ assert.eq(queryStatsResults.length, 1, queryStatsResults);
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.db, "test");
+ assert.eq(queryStatsResults[0].key.queryShape.cmdNs.coll, jsTestName());
+ assert.eq(queryStatsResults[0].key.client.application.name, "MongoDB Shell");
+ assert.eq(queryStatsResults[0].metrics.execCount, 2);
+ assert.eq(queryStatsResults[0].metrics.docsReturned.sum, numDocs / 2 + 2 * fooEqBatchSize);
+ assert.eq(queryStatsResults[0].metrics.docsReturned.max, numDocs / 2);
+ assert.eq(queryStatsResults[0].metrics.docsReturned.min, 2 * fooEqBatchSize);
+}
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_regex.js b/jstests/noPassthrough/queryStats/query_stats_regex.js
new file mode 100644
index 00000000000..8b9553e267f
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_regex.js
@@ -0,0 +1,43 @@
+/**
+ * Test that queryStats works properly for a find command that uses regex.
+ * @tags: [requires_fcv_60]
+ */
+(function() {
+"use strict";
+
+load("jstests/libs/query_stats_utils.js"); // For getLatestQueryStatsEntry.
+
+// Turn on the collecting of queryStats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+const bulk = coll.initializeUnorderedBulkOp();
+const numDocs = 100;
+for (let i = 0; i < numDocs / 2; ++i) {
+ bulk.insert({foo: "ABCDE"});
+ bulk.insert({foo: "CDEFG"});
+}
+assert.commandWorked(bulk.execute());
+
+{
+ coll.find({foo: {$regex: "/^ABC/i"}}).itcount();
+ const queryStats = getLatestQueryStatsEntry(testDB);
+ assert.eq({"foo": {"$regex": "?string"}}, queryStats.key.queryShape.filter, queryStats);
+}
+
+{
+ coll.find({foo: {$regex: ".*", $options: "m"}}).itcount();
+ const queryStats = getLatestQueryStatsEntry(testDB);
+ assert.eq({"foo": {"$regex": "?string", "$options": "?string"}},
+ queryStats.key.queryShape.filter,
+ queryStats);
+}
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_sampling_rate.js b/jstests/noPassthrough/queryStats/query_stats_sampling_rate.js
new file mode 100644
index 00000000000..b25fb3318d9
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_sampling_rate.js
@@ -0,0 +1,37 @@
+/**
+ * Test that calls to read from query stats store fail when sampling rate is not greater than 0 even
+ * if feature flag is on.
+ * @tags: [requires_fcv_60]
+ */
+load('jstests/libs/analyze_plan.js');
+load("jstests/libs/query_stats_utils.js");
+
+(function() {
+"use strict";
+
+let options = {
+ setParameter: {internalQueryStatsRateLimit: 0},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testdb = conn.getDB('test');
+var coll = testdb[jsTestName()];
+coll.drop();
+for (var i = 0; i < 20; i++) {
+ coll.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+}
+
+coll.find({foo: 1}).batchSize(2).toArray();
+
+// Reading query stats store with a sampling rate of 0 should return 0 documents.
+let stats = getQueryStats(testdb);
+assert.eq(stats.length, 0);
+
+// Reading query stats store should work now with a sampling rate of greater than 0.
+assert.commandWorked(testdb.adminCommand({setParameter: 1, internalQueryStatsRateLimit: -1}));
+coll.find({foo: 1}).batchSize(2).toArray();
+stats = getQueryStats(testdb);
+assert.eq(stats.length, 1);
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_server_status_metrics.js b/jstests/noPassthrough/queryStats/query_stats_server_status_metrics.js
new file mode 100644
index 00000000000..ee6498b45cc
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_server_status_metrics.js
@@ -0,0 +1,272 @@
+/**
+ * Test the queryStats related serverStatus metrics.
+ * @tags: [requires_fcv_60]
+ */
+(function() {
+"use strict";
+
+function runTestWithMongodOptions(mongodOptions, test, testOptions) {
+ const conn = MongoRunner.runMongod(mongodOptions);
+ const testDB = conn.getDB('test');
+ const coll = testDB[jsTestName()];
+
+ test(conn, testDB, coll, testOptions);
+
+ MongoRunner.stopMongod(conn);
+}
+
+// Helper to round up to the next highest power of 2 for our estimation.
+function align(number) {
+ return Math.pow(2, Math.ceil(Math.log2(number)));
+}
+
+function addApprox2MBOfStatsData(testDB, coll) {
+ const k2MB = 2 * 1024 * 1024;
+
+ const cmdObjTemplate = {
+ find: coll.getName(),
+ filter: {foo123: {$eq: "?"}},
+ };
+
+ const kEstimatedEntrySizeBytes = (() => {
+ // Metrics stored per shape.
+ const kNumCountersAndDates =
+ 4 /* top-level */ + (4 * 3) /* those with sum, min, max, sumOfSquares */;
+
+ // Just a sample, will change based on where the test is run - shouldn't be off by too much
+ // though.
+ const kClientMetadataEst = {
+ client: {application: {name: "MongoDB Shell"}},
+ driver: {name: "MongoDB Internal Client", version: "7.1.0-alpha"},
+ os: {type: "Linux", name: "Ubuntu", architecture: "aarch64", version: "22.04"}
+ };
+
+ const kCmdNsObj = {cmdNs: {db: testDB.getName(), coll: coll.getName()}};
+
+ // Rough estimate of space needed for just the query stats 'Key' class members assuming
+ // everything is 8 bytes.
+ const kCxxOverhead = 96;
+
+ // This is likely not to be exact - we are probably forgetting something. But we don't need
+ // to be exact, just "good enough."
+ return align(kNumCountersAndDates * 4 + Object.bsonsize(cmdObjTemplate) +
+ Object.bsonsize(kClientMetadataEst) + Object.bsonsize(kCmdNsObj) +
+ kCxxOverhead);
+ })();
+ const nIterations = k2MB / kEstimatedEntrySizeBytes;
+ for (let i = 0; i <= nIterations; i++) {
+ let newQuery = {["foo" + i]: "bar"};
+ const cmdObj = cmdObjTemplate;
+ cmdObj.filter = newQuery;
+ const cmdRes = assert.commandWorked(testDB.runCommand(cmdObj));
+ new DBCommandCursor(testDB, cmdRes).itcount();
+ }
+}
+/**
+ * Test serverStatus metric which counts the number of evicted entries.
+ *
+ * testOptions must include `resetCacheSize` bool field; e.g., { resetCacheSize : true }
+ */
+function evictionTest(conn, testDB, coll, testOptions) {
+ const evictedBefore = testDB.serverStatus().metrics.queryStats.numEvicted;
+ assert.eq(evictedBefore, 0);
+ addApprox2MBOfStatsData(testDB, coll);
+ if (!testOptions.resetCacheSize) {
+ const evictedAfter = testDB.serverStatus().metrics.queryStats.numEvicted;
+ assert.gt(evictedAfter, 0, testDB.serverStatus().metrics.queryStats);
+ return;
+ }
+ // Make sure number of evicted entries increases when the cache size is reset, which forces out
+ // least recently used entries to meet the new, smaller size requirement.
+ assert.eq(testDB.serverStatus().metrics.queryStats.numEvicted, 0);
+ assert.commandWorked(
+ testDB.adminCommand({setParameter: 1, internalQueryStatsCacheSize: "1MB"}));
+ const evictedAfter = testDB.serverStatus().metrics.queryStats.numEvicted;
+ assert.gt(evictedAfter, 0);
+}
+
+/**
+ * Test serverStatus metric which counts the number of requests for which queryStats is not
+ * collected due to rate-limiting.
+ *
+ * testOptions must include `samplingRate` and `numRequests` number fields;
+ * e.g., { samplingRate: -1, numRequests: 20 }
+ */
+function countRateLimitedRequestsTest(conn, testDB, coll, testOptions) {
+ const numRateLimitedRequestsBefore =
+ testDB.serverStatus().metrics.queryStats.numRateLimitedRequests;
+ assert.eq(numRateLimitedRequestsBefore, 0);
+
+ coll.insert({a: 0});
+
+ // Running numRequests / 2 times since we dispatch two requests per iteration
+ for (var i = 0; i < testOptions.numRequests / 2; i++) {
+ coll.find({a: 0}).toArray();
+ coll.aggregate([{$match: {a: 1}}]);
+ }
+
+ const numRateLimitedRequestsAfter =
+ testDB.serverStatus().metrics.queryStats.numRateLimitedRequests;
+
+ if (testOptions.samplingRate === 0) {
+ // queryStats should not be collected for any requests.
+ assert.eq(numRateLimitedRequestsAfter, testOptions.numRequests);
+ } else if (testOptions.samplingRate >= testOptions.numRequests) {
+ // queryStats should be collected for all requests.
+ assert.eq(numRateLimitedRequestsAfter, 0);
+ } else {
+ // queryStats should be collected for some but not all requests.
+ assert.gt(numRateLimitedRequestsAfter, 0);
+ assert.lt(numRateLimitedRequestsAfter, testOptions.numRequests);
+ }
+}
+
+function queryStatsStoreSizeEstimateTest(conn, testDB, coll, testOptions) {
+ assert.eq(testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes, 0);
+ let halfWayPointSize;
+ // Only using three digit numbers (eg 100, 101) means the string length will be the same for all
+ // entries and therefore the key size will be the same for all entries, which makes predicting
+ // the total size of the store clean and easy.
+ for (var i = 100; i < 200; i++) {
+ coll.aggregate([{$match: {["foo" + i]: "bar"}}]).itcount();
+ if (i == 150) {
+ halfWayPointSize =
+ testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes;
+ }
+ }
+ // Confirm that queryStats store has grown and size is non-zero.
+ assert.gt(halfWayPointSize, 0);
+ const fullSize = testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes;
+ assert.gt(fullSize, 0);
+ // Make sure the final queryStats store size is twice as much as the halfway point size (+/- 5%)
+ assert(fullSize >= halfWayPointSize * 1.95 && fullSize <= halfWayPointSize * 2.05,
+ tojson({fullSize, halfWayPointSize}));
+}
+
+function queryStatsStoreWriteErrorsTest(conn, testDB, coll, testOptions) {
+ const debugBuild = testDB.adminCommand('buildInfo').debug;
+ if (debugBuild) {
+ jsTestLog("Skipping queryStats store write errors test because debug build will tassert.");
+ return;
+ }
+
+ const errorsBefore = testDB.serverStatus().metrics.queryStats.numQueryStatsStoreWriteErrors;
+ assert.eq(errorsBefore, 0);
+ for (let i = 0; i < 5; i++) {
+ // Command should succeed and record the error.
+ let query = {};
+ query["foo" + i] = "bar";
+ coll.aggregate([{$match: query}]).itcount();
+ }
+
+ // Make sure that we recorded a write error for each run.
+ assert.eq(testDB.serverStatus().metrics.queryStats.numQueryStatsStoreWriteErrors, 5);
+}
+
+/**
+ * Test that running the $queryStats aggregation stage correctly does or does not impact
+ * serverStatus counters (whichever is applicable).
+ */
+function queryStatsAggregationStageTest(conn, testDB, coll) {
+ // First, ensure that the query stats store is empty.
+ assert.eq(testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes, 0);
+ assert.eq(testDB.serverStatus().metrics.queryStats.numEvicted, 0);
+
+ // Insert some query stats data and capture "before" serverStatus metrics.
+ for (let i = 100; i < 200; i++) {
+ coll.aggregate([{$match: {["foo" + i]: "bar"}}]).itcount();
+ }
+
+ let sizeBefore = testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes;
+ const evictedBefore = testDB.serverStatus().metrics.queryStats.numEvicted;
+
+ // Run a $queryStats pipeline. We should insert a new entry for this query.
+ assert.commandWorked(
+ testDB.adminCommand({aggregate: 1, pipeline: [{$queryStats: {}}], cursor: {}}));
+
+ assert.eq(testDB.serverStatus().metrics.queryStats.numEvicted,
+ evictedBefore,
+ "$queryStats should not have triggered evictions");
+ assert.gt(testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes,
+ sizeBefore,
+ "$queryStats pipeline should have been added to the query stats store");
+ sizeBefore = testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes;
+
+ // Now run $queryStats again. The command should be fully read-only.
+ assert.commandWorked(
+ testDB.adminCommand({aggregate: 1, pipeline: [{$queryStats: {}}], cursor: {}}));
+
+ assert.eq(testDB.serverStatus().metrics.queryStats.numEvicted,
+ evictedBefore,
+ "$queryStats should not have triggered evictions");
+ assert.eq(testDB.serverStatus().metrics.queryStats.queryStatsStoreSizeEstimateBytes,
+ sizeBefore,
+ "$queryStats pipeline should not have impacted query stats store size");
+}
+
+/**
+ * In this configuration, we insert enough entries into the queryStats store to trigger LRU
+ * eviction.
+ */
+runTestWithMongodOptions({
+ setParameter: {internalQueryStatsCacheSize: "1MB", internalQueryStatsRateLimit: -1},
+},
+ evictionTest,
+ {resetCacheSize: false});
+/**
+ * In this configuration, eviction is triggered only when the queryStats store size is reset.
+ *
+ * Use an 8MB upper limit since our estimated size of the query stats entry is pretty rough and
+ * meant to give us some wiggle room so we don't have to keep adjusting this test as we tweak it.
+ */
+runTestWithMongodOptions({
+ setParameter: {internalQueryStatsCacheSize: "8MB", internalQueryStatsRateLimit: -1},
+},
+ evictionTest,
+ {resetCacheSize: true});
+
+/**
+ * In this configuration, every query is sampled, so no requests should be rate-limited.
+ */
+runTestWithMongodOptions({
+ setParameter: {internalQueryStatsRateLimit: -1},
+},
+ countRateLimitedRequestsTest,
+ {samplingRate: 2147483647, numRequests: 20});
+
+/**
+ * In this configuration, the sampling rate is set so that some but not all requests are
+ * rate-limited.
+ */
+runTestWithMongodOptions({
+ setParameter: {internalQueryStatsRateLimit: 10},
+},
+ countRateLimitedRequestsTest,
+ {samplingRate: 10, numRequests: 20});
+
+/**
+ * Sample all queries and assert that the size of queryStats store is equal to num entries * entry
+ * size
+ */
+runTestWithMongodOptions({
+ setParameter: {internalQueryStatsRateLimit: -1},
+},
+ queryStatsStoreSizeEstimateTest);
+
+/**
+ * Use a very small queryStats store size and assert that errors in writing to the queryStats store
+ * are tracked.
+ */
+runTestWithMongodOptions({
+ setParameter: {internalQueryStatsCacheSize: "0.00001MB", internalQueryStatsRateLimit: -1},
+},
+ queryStatsStoreWriteErrorsTest);
+
+/**
+ * Tests that $queryStats has expected effects (or no effect) on counters.
+ */
+runTestWithMongodOptions({
+ setParameter: {internalQueryStatsCacheSize: "2MB", internalQueryStatsRateLimit: -1},
+},
+ queryStatsAggregationStageTest);
+}());
diff --git a/jstests/noPassthrough/queryStats/query_stats_sub_pipelines.js b/jstests/noPassthrough/queryStats/query_stats_sub_pipelines.js
new file mode 100644
index 00000000000..7e74d66f20c
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_sub_pipelines.js
@@ -0,0 +1,184 @@
+/**
+ * Test that shapification for queries with sub-pipelines happens before view resolution and
+ * pipeline optimization.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For getLatestQueryStatsEntry
+
+const conn = MongoRunner.runMongod({
+ setParameter: {
+ internalQueryStatsRateLimit: -1,
+ }
+});
+
+const dbName = jsTestName();
+const db = conn.getDB(dbName);
+
+const collName = "base";
+const coll = db[collName];
+coll.drop();
+assert.commandWorked(coll.insert({x: 10, y: 20}));
+
+const viewName = "projection_view";
+assert.commandWorked(
+ db.runCommand({create: viewName, viewOn: collName, pipeline: [{$project: {_id: 1, x: 1}}]}));
+const view = db[viewName];
+
+// Tests a basic query on a view as a sanity check.
+(function testViewQuery() {
+ view.aggregate([{$sort: {x: 1}}]).toArray();
+
+ const stats = getLatestQueryStatsEntry(db);
+ const key = stats.key;
+
+ // The view should not be resolved in the key.
+ assert.eq({"db": `${dbName}`, "coll": `${viewName}`}, key.queryShape.cmdNs, tojson(key));
+ assert.eq(
+ [
+ {"$sort": {"x": 1}},
+ ],
+ key.queryShape.pipeline,
+ tojson(key));
+})();
+
+// Tests a query where a view reference is in a $lookup pipeline.
+(function testViewInLookupPipeline() {
+ coll.aggregate([{$lookup: {from: viewName, pipeline: [{$match: {x: 10}}], as: "lookup"}}])
+ .toArray();
+
+ const stats = getLatestQueryStatsEntry(db);
+ const key = stats.key;
+
+ // The view should not be resolved in the key.
+ assert.eq({"db": `${dbName}`, "coll": `${collName}`}, key.queryShape.cmdNs, tojson(key));
+ assert.eq(
+ [
+ {$lookup:
+ {
+ from: viewName,
+ as: "lookup",
+ let: {},
+ pipeline: [{$match: {x: {$eq: "?number"}}}],
+ }
+ }
+ ],
+ key.queryShape.pipeline, tojson(key));
+})();
+
+// Tests that the $lookup x $match optimization is applied after shapification.
+(function testLookupMatchOptimization() {
+ coll.aggregate([
+ {$lookup: {from: viewName, pipeline: [{$match: {x: {$gt: 5}}}], as: "lookup"}},
+ {$unwind: {path: "$lookup"}},
+ {$match: {"lookup.x": 10}}
+ ])
+ .toArray();
+
+ const stats = getLatestQueryStatsEntry(db);
+ const key = stats.key;
+
+ // The view should not be resolved in the key.
+ assert.eq({"db": `${dbName}`, "coll": `${collName}`}, key.queryShape.cmdNs, tojson(key));
+ assert.eq(
+ [
+ {$lookup:
+ {
+ from: viewName,
+ as: "lookup",
+ let: {},
+ pipeline: [{$match: {x: {$gt: "?number"}}}],
+ }
+ },
+ {$unwind: {
+ path: "$lookup"
+ }},
+ {$match: {
+ "lookup.x": {
+ $eq: "?number"
+ }
+ }}
+ ],
+ key.queryShape.pipeline, tojson(key));
+})();
+
+// Tests that the $lookup x $unwind optimization is applied after shapification.
+(function testLookupUnwindOptimization() {
+ coll.aggregate([
+ {$lookup: {from: viewName, pipeline: [{$match: {x: 10}}], as: "lookup"}},
+ {$unwind: {path: "$lookup"}}
+ ])
+ .toArray();
+
+ const stats = getLatestQueryStatsEntry(db);
+ const key = stats.key;
+
+ // The view should not be resolved in the key.
+ assert.eq({"db": `${dbName}`, "coll": `${collName}`}, key.queryShape.cmdNs, tojson(key));
+ assert.eq(
+ [
+ {$lookup:
+ {
+ from: viewName,
+ as: "lookup",
+ let: {},
+ pipeline: [{$match: {x: {$eq: "?number"}}}],
+ }
+ },
+ {$unwind: {
+ path: "$lookup"
+ }}
+ ],
+ key.queryShape.pipeline, tojson(key));
+})();
+
+// Tests a query where a view reference is in a $graphLookup pipeline.
+(function testViewInGraphLookupPipeline() {
+ coll.aggregate([{$graphLookup: {from: viewName, startWith: "$x", connectFromField: "x", connectToField: "x", as: "lookup"}}]).toArray();
+
+ const stats = getLatestQueryStatsEntry(db);
+ const key = stats.key;
+
+ // The view should not be resolved in the key.
+ assert.eq({"db": `${dbName}`, "coll": `${collName}`}, key.queryShape.cmdNs, tojson(key));
+ assert.eq(
+ [
+ {$graphLookup:
+ {
+ from: viewName,
+ as: "lookup",
+ connectToField: "x",
+ connectFromField: "x",
+ startWith: "$x"
+ }
+ }
+ ],
+ key.queryShape.pipeline, tojson(key));
+})();
+
+// Tests a query where a view reference is in a $unionWith.
+(function testViewInUnionWith() {
+ coll.aggregate([{$unionWith: viewName}]).toArray();
+
+ const stats = getLatestQueryStatsEntry(db);
+ const key = stats.key;
+
+ // The view should not be resolved in the key.
+ assert.eq({"db": `${dbName}`, "coll": `${collName}`}, key.queryShape.cmdNs, tojson(key));
+ assert.eq([{$unionWith: {coll: viewName, pipeline: []}}], key.queryShape.pipeline, tojson(key));
+})();
+
+// Tests a query where a view reference is in a $unionWith with a sub-pipeline.
+(function testViewInUnionWithPipeline() {
+ coll.aggregate([{$unionWith: {coll: viewName, pipeline: [{$sort: {x: 1}}]}}]).toArray();
+
+ const stats = getLatestQueryStatsEntry(db);
+ const key = stats.key;
+
+ // The view should not be resolved in the key.
+ assert.eq({"db": `${dbName}`, "coll": `${collName}`}, key.queryShape.cmdNs, tojson(key));
+ assert.eq([{$unionWith: {coll: viewName, pipeline: [{$sort: {x: 1}}]}}],
+ key.queryShape.pipeline,
+ tojson(key));
+})();
+
+MongoRunner.stopMongod(conn);
diff --git a/jstests/noPassthrough/queryStats/query_stats_upgrade.js b/jstests/noPassthrough/queryStats/query_stats_upgrade.js
new file mode 100644
index 00000000000..de23b157e99
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/query_stats_upgrade.js
@@ -0,0 +1,45 @@
+/**
+ * Test that query stats doesn't work on a lower FCV version but works after an FCV upgrade.
+ * @tags: [
+ * requires_fcv_60,
+ * # Re-uses FCV state in the dbpath.
+ * requires_persistence
+ * ]
+ */
+load('jstests/libs/analyze_plan.js');
+load("jstests/libs/feature_flag_util.js");
+
+(function() {
+"use strict";
+
+const dbpath = MongoRunner.dataPath + jsTestName();
+let conn = MongoRunner.runMongod({dbpath: dbpath});
+let testDB = conn.getDB(jsTestName());
+
+function testLower(restart = false) {
+ let adminDB = conn.getDB("admin");
+ assert.commandWorked(
+ adminDB.runCommand({setFeatureCompatibilityVersion: binVersionToFCV("last-lts")}));
+ if (restart) {
+ MongoRunner.stopMongod(conn);
+ conn = MongoRunner.runMongod({dbpath: dbpath, noCleanData: true});
+ testDB = conn.getDB(jsTestName());
+ adminDB = conn.getDB("admin");
+ }
+
+ assert.commandFailedWithCode(
+ testDB.adminCommand({aggregate: 1, pipeline: [{$queryStats: {}}], cursor: {}}),
+ ErrorCodes.QueryFeatureNotAllowed);
+
+ // Upgrade FCV.
+ assert.commandWorked(
+ adminDB.runCommand({setFeatureCompatibilityVersion: binVersionToFCV("latest")}));
+
+ // We should be able to run a query stats pipeline now that the FCV is correct.
+ assert.commandWorked(
+ testDB.adminCommand({aggregate: 1, pipeline: [{$queryStats: {}}], cursor: {}}));
+}
+testLower(true);
+testLower(false);
+MongoRunner.stopMongod(conn);
+})(); \ No newline at end of file
diff --git a/jstests/noPassthrough/queryStats/redact_queries_with_nonobject_fields.js b/jstests/noPassthrough/queryStats/redact_queries_with_nonobject_fields.js
new file mode 100644
index 00000000000..7bc0737f699
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/redact_queries_with_nonobject_fields.js
@@ -0,0 +1,76 @@
+/**
+ * Test that query stats key generation works for queries with non-object fields.
+ * @tags: [requires_fcv_60]
+ */
+load('jstests/libs/analyze_plan.js');
+
+(function() {
+"use strict";
+
+// Turn on the collecting of query stats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var collA = testDB[jsTestName()];
+var collB = testDB[jsTestName() + 'Two'];
+collA.drop();
+collB.drop();
+
+for (var i = 0; i < 200; i++) {
+ collA.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+ collA.insert({foo: 1, bar: Math.floor(Math.random() * -2)});
+ collB.insert({foo: Math.floor(Math.random() * 2), bar: Math.floor(Math.random() * 2)});
+}
+
+function confirmAggSuccess(collName, pipeline) {
+ const command = {aggregate: collName, cursor: {}};
+ command.pipeline = pipeline;
+ assert.commandWorked(testDB.runCommand(command));
+}
+// Test with non-object fields $limit and $skip.
+confirmAggSuccess(collA.getName(), [{$sort: {bar: -1}}, {$limit: 2}, {$match: {foo: {$lte: 2}}}]);
+confirmAggSuccess(collA.getName(), [{$sort: {bar: -1}}, {$skip: 50}, {$match: {foo: {$lte: 2}}}]);
+confirmAggSuccess(collA.getName(),
+ [{$sort: {bar: -1}}, {$limit: 2}, {$skip: 50}, {$match: {foo: 0}}]);
+
+// Test non-object field, $unionWith.
+confirmAggSuccess(collA.getName(), [{$unionWith: collB.getName()}]);
+
+// Test $limit in $setWindowFields for good measure.
+confirmAggSuccess(collA.getName(), [
+ {$_internalInhibitOptimization: {}},
+ {
+ $setWindowFields: {
+ sortBy: {foo: 1},
+ output: {sum: {$sum: "$bar", window: {documents: ["unbounded", "current"]}}}
+ }
+ },
+ {$sort: {foo: 1}},
+ {$limit: 5}
+]);
+// Test find commands containing non-object fields
+assert.commandWorked(testDB.runCommand({find: collA.getName(), limit: 20}));
+assert.commandWorked(testDB.runCommand({find: collA.getName(), skip: 199}));
+collA.find().skip(100);
+
+// findOne has a nonobject field, $limit.
+collB.findOne();
+collB.findOne({foo: 1});
+
+// Test non-object field $unwind
+confirmAggSuccess(
+ collA.getName(), [{
+ "$facet": {
+ "productOfJoin": [
+ {"$lookup": {"from": collB.getName(), "pipeline": [{"$match": {}}], "as": "join"}},
+ {"$unwind": "$join"},
+ {"$project": {"str": 1}}
+ ]
+ }
+ }]);
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/redact_sensitive_fields_in_log.js b/jstests/noPassthrough/queryStats/redact_sensitive_fields_in_log.js
new file mode 100644
index 00000000000..498603ba5da
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/redact_sensitive_fields_in_log.js
@@ -0,0 +1,60 @@
+/**
+ * Test that the queryStats HMAC key is not logged.
+ * @tags: [requires_fcv_60]
+ */
+
+(function() {
+'use strict';
+
+load('jstests/libs/query_stats_utils.js'); // For getQueryStatsFindCmd
+
+const checkLogForHmacKey = function(conn) {
+ const coll = conn.getCollection("test.foo");
+ assert.commandWorked(coll.insert({_id: 1}));
+ assert.neq(coll.findOne({_id: 1}), null);
+
+ assert.neq(getQueryStatsFindCmd(conn, {
+ transformIdentifiers: true,
+ hmacKey: BinData(8, "YW4gYXJiaXRyYXJ5IEhNQUNrZXkgZm9yIHRlc3Rpbmc=")
+ }),
+ null);
+
+ print(`Checking ${conn.fullOptions.logFile} for query stats message`);
+ const log = cat(conn.fullOptions.logFile);
+
+ // Make sure there is no unredacted HMAC key
+ const predicate = /"hmacKey":"[^#"]+"/;
+ assert(!predicate.test(log),
+ "Found an unredacted HMAC key in log file!\n" +
+ "Log file contents: " + conn.fullOptions.logFile +
+ "\n************************************************************\n" + log +
+ "\n************************************************************");
+};
+
+// Test MongoD
+const testMongoD = function() {
+ const conn =
+ MongoRunner.runMongod({setParameter: {internalQueryStatsRateLimit: -1}, useLogFiles: true});
+ assert.neq(null, conn, 'mongod was unable to start up');
+
+ checkLogForHmacKey(conn);
+
+ MongoRunner.stopMongod(conn);
+};
+
+// Test MongoS
+const testMongoS = function() {
+ const options = {
+ mongosOptions: {setParameter: {internalQueryStatsRateLimit: -1}, useLogFiles: true},
+ };
+
+ const st = new ShardingTest({shards: 1, mongos: 1, other: options});
+
+ checkLogForHmacKey(st.s0);
+
+ st.stop();
+};
+
+testMongoD();
+testMongoS();
+})();
diff --git a/jstests/noPassthrough/queryStats/redact_sensitive_fields_in_profile.js b/jstests/noPassthrough/queryStats/redact_sensitive_fields_in_profile.js
new file mode 100644
index 00000000000..1b37a0ad94a
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/redact_sensitive_fields_in_profile.js
@@ -0,0 +1,63 @@
+/**
+ * Test that the queryStats HMAC key is not leaked during profiling.
+ * @tags: [requires_fcv_60]
+ */
+(function() {
+"use strict";
+
+load("jstests/libs/profiler.js"); // For getLatestProfilerEntry.
+load("jstests/libs/query_stats_utils.js"); // For getQueryStatsFindCmd
+
+const conn = MongoRunner.runMongod({setParameter: {internalQueryStatsRateLimit: -1}, profile: 2});
+const adminDB = conn.getDB("admin");
+const testDB = conn.getDB("test");
+const coll = testDB[jsTestName()];
+
+// Prepopulate
+coll.drop();
+assert.commandWorked(coll.insert([{foo: 0}, {foo: 1}]));
+
+const nsProfilerFilter = {
+ ns: coll.getFullName()
+};
+
+// Run a few find commands
+for (const index of Array(10).keys()) {
+ if (index < 2) {
+ assert.neq(coll.findOne({foo: index}), null, `{foo: ${index}} should be extant`);
+ } else {
+ assert.isnull(coll.findOne({foo: index}), `{foo: ${index}} should be null`);
+ }
+}
+
+// This returns `null` when there are no entries, as opposed to erroring when getLatestProfilerEntry
+// encounters an empty profile.
+const getLastAdminEntry = () => {
+ const cursor =
+ adminDB.system.profile.find({ns: "admin.$cmd.aggregate"}).sort({$natural: -1}).limit(1);
+ return [...cursor.toArray(), null][0];
+};
+
+const lastTestEntry = getLatestProfilerEntry(testDB, nsProfilerFilter);
+const lastAdminEntry = getLastAdminEntry();
+const hmacKey = BinData(8, "YW4gYXJiaXRyYXJ5IEhNQUNrZXkgZm9yIHRlc3Rpbmc=");
+
+getQueryStatsFindCmd(conn, {transformIdentifiers: true, hmacKey: hmacKey});
+
+// Check that the queryStats command is not profiled.
+assert.eq(getLatestProfilerEntry(testDB, nsProfilerFilter), lastTestEntry);
+
+// Check that the queryStats command is profiled in the admin log either.
+const adminEntry = getLastAdminEntry();
+assert.neq(adminEntry, lastAdminEntry);
+
+// Check that the HMAC key is redacted.
+const loggedHmacKey = adminEntry.command.pipeline[0].$queryStats.transformIdentifiers.hmacKey;
+assert.neq(loggedHmacKey, hmacKey);
+
+// This is somewhat implementation dependent. If the redaction string ever changes, this will need
+// to be updated as well.
+assert.eq(loggedHmacKey, "###");
+
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/repl_set_query_stats_key.js b/jstests/noPassthrough/queryStats/repl_set_query_stats_key.js
new file mode 100644
index 00000000000..309e0b9ff75
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/repl_set_query_stats_key.js
@@ -0,0 +1,108 @@
+/**
+ * This test confirms that queryStats store key fields specific to replica sets (readConcern and
+ * readPreference) are included and correctly shapified. General command fields related to api
+ * versioning are included for good measure.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For getLatestQueryStatsEntry
+(function() {
+"use strict";
+
+const replTest = new ReplSetTest({
+ name: 'queryStatsTest',
+ nodes: [
+ {rsConfig: {tags: {dc: "east"}}},
+ {rsConfig: {tags: {dc: "west"}}},
+ ]
+});
+
+// Turn on the collecting of query stats metrics.
+replTest.startSet({setParameter: {internalQueryStatsRateLimit: -1}});
+replTest.initiate();
+
+const primary = replTest.getPrimary();
+
+const dbName = jsTestName();
+const collName = "foobar";
+const primaryDB = primary.getDB(dbName);
+const primaryColl = primaryDB.getCollection(collName);
+
+primaryColl.drop();
+
+const clusterTime =
+ assert.commandWorked(primaryDB.runCommand({insert: collName, documents: [{a: 1000}]}))
+ .operationTime;
+
+replTest.awaitReplication();
+
+function confirmCommandFieldsPresent(queryStatsKey, commandObj) {
+ for (const field in queryStatsKey) {
+ if (field == "queryShape" || field == "client" || field == "command") {
+ continue;
+ }
+ assert(commandObj.hasOwnProperty(field),
+ `${field} is present in the query stats key but not present in command obj: ${
+ tojson(queryStatsKey)}, ${tojson(commandObj)}`);
+ }
+ assert.eq(Object.keys(queryStatsKey).length, Object.keys(commandObj).length, queryStatsKey);
+}
+
+let commandObj = {
+ find: collName,
+ filter: {v: {$eq: 2}},
+ readConcern: {level: "local", afterClusterTime: new Timestamp(0, 1)},
+ $readPreference: {mode: "nearest", tags: [{some: "tag"}, {dc: "north pole"}, {dc: "east"}]},
+ apiDeprecationErrors: false,
+ apiVersion: "1",
+ apiStrict: false,
+};
+const replSetConn = new Mongo(replTest.getURL());
+assert.commandWorked(replSetConn.getDB(dbName).runCommand(commandObj));
+let stats = getLatestQueryStatsEntry(replSetConn, {collName: collName});
+delete stats.key["collectionType"];
+confirmCommandFieldsPresent(stats.key, commandObj);
+// Check that readConcern afterClusterTime is normalized.
+assert.eq(stats.key.readConcern.afterClusterTime, "?timestamp", tojson(stats.key.readConcern));
+
+// Check that $readPreference.tags are sorted.
+assert.eq(stats.key.$readPreference.tags,
+ [{dc: "east"}, {dc: "north pole"}, {some: "tag"}],
+ tojson(stats.key.$readPreference));
+
+// Check that readConcern just has an afterClusterTime field.
+commandObj["readConcern"] = {
+ afterClusterTime: new Timestamp(1, 0)
+};
+delete commandObj["$readPreference"];
+assert.commandWorked(replSetConn.getDB(dbName).runCommand(commandObj));
+stats = getLatestQueryStatsEntry(replSetConn, {collName});
+// We're not concerned with this field here.
+delete stats.key["collectionType"];
+confirmCommandFieldsPresent(stats.key, commandObj);
+assert.eq(stats.key["readConcern"], {"afterClusterTime": "?timestamp"});
+
+// Check that readConcern has no afterClusterTime and fields related to api usage are not present.
+commandObj["readConcern"] = {
+ level: "local"
+};
+delete commandObj["apiDeprecationErrors"];
+delete commandObj["apiVersion"];
+delete commandObj["apiStrict"];
+assert.commandWorked(replSetConn.getDB(dbName).runCommand(commandObj));
+stats = getLatestQueryStatsEntry(replSetConn, {collName: collName});
+assert.eq(stats.key["readConcern"], {level: "local"});
+// We're not concerned with this field here.
+delete stats.key["collectionType"];
+confirmCommandFieldsPresent(stats.key, commandObj);
+
+// Check that the 'atClusterTime' parameter is shapified correctly.
+commandObj.readConcern = {
+ level: "snapshot",
+ atClusterTime: clusterTime
+};
+assert.commandWorked(replSetConn.getDB(dbName).runCommand(commandObj));
+stats = getLatestQueryStatsEntry(replSetConn, {collName: collName});
+assert.eq(stats.key["readConcern"], {level: "snapshot", atClusterTime: "?timestamp"});
+
+replTest.stopSet();
+})();
diff --git a/jstests/noPassthrough/queryStats/single_batch_on_mongod_agg_cmd.js b/jstests/noPassthrough/queryStats/single_batch_on_mongod_agg_cmd.js
new file mode 100644
index 00000000000..d3c605c61d8
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/single_batch_on_mongod_agg_cmd.js
@@ -0,0 +1,61 @@
+/**
+ * Test that the queryStats metrics are aggregated properly for queries run on a mongod where the
+ * results fit into a single batch (and thus don't require a cursor), for agg commands.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For verifyMetrics and getQueryStatsAggCmd.
+
+(function() {
+"use strict";
+
+// Turn on the collecting of queryStats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+// Bulk insert documents to reduces roundtrips and make timeout on a slow machine less likely.
+const bulk = coll.initializeUnorderedBulkOp();
+const numDocs = 100;
+for (let i = 0; i < numDocs / 2; ++i) {
+ bulk.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+ bulk.insert({foo: 1, bar: Math.floor(Math.random() * -2)});
+}
+assert.commandWorked(bulk.execute());
+
+// Assert that two agg queries with identical structures are represented by the same key.
+
+// Note that toArray is necessary to guarantee the query finishes executing on the server (at
+// which point an entry is finally written to the queryStats store).
+coll.aggregate([{$match: {foo: 1}}]).toArray();
+coll.aggregate([{$match: {foo: 0}}]).toArray();
+
+// This command will return all queryStats store entires.
+const queryStatsResults = getQueryStatsAggCmd(testDB);
+// Assert there is only one entry.
+assert.eq(queryStatsResults.length, 1, queryStatsResults);
+const queryStatsEntry = queryStatsResults[0];
+assert.eq(queryStatsEntry.key.queryShape.cmdNs.db, "test");
+assert.eq(queryStatsEntry.key.queryShape.cmdNs.coll, jsTestName());
+assert.eq(queryStatsEntry.key.client.application.name, "MongoDB Shell");
+
+// Assert we update execution count for identically shaped queries.
+assert.eq(queryStatsEntry.metrics.execCount, 2);
+
+// Assert queryStats values are accurate for the two above queries.
+assert.eq(queryStatsEntry.metrics.docsReturned.sum, numDocs);
+assert.eq(queryStatsEntry.metrics.docsReturned.min, numDocs / 2);
+assert.eq(queryStatsEntry.metrics.docsReturned.max, numDocs / 2);
+
+// The total size of documents in the collection should ensure that the queries in this test can
+// be executed without requiring multiple batches, but we verify that by looking at the
+// timestamps.
+assert.eq(queryStatsEntry.metrics.firstResponseExecMicros, queryStatsEntry.metrics.totalExecMicros);
+
+verifyMetrics(queryStatsResults);
+MongoRunner.stopMongod(conn);
+}());
diff --git a/jstests/noPassthrough/queryStats/single_batch_on_mongod_find_cmd.js b/jstests/noPassthrough/queryStats/single_batch_on_mongod_find_cmd.js
new file mode 100644
index 00000000000..84c13b942e3
--- /dev/null
+++ b/jstests/noPassthrough/queryStats/single_batch_on_mongod_find_cmd.js
@@ -0,0 +1,59 @@
+/**
+ * Test that the queryStats metrics are aggregated properly for queries run on a mongod where the
+ * results fit into a single batch (and thus don't require a cursor), for find commands.
+ * @tags: [requires_fcv_60]
+ */
+load("jstests/libs/query_stats_utils.js"); // For verifyMetrics and getQueryStatsAggCmd.
+
+(function() {
+"use strict";
+
+// Turn on the collecting of queryStats metrics.
+let options = {
+ setParameter: {internalQueryStatsRateLimit: -1},
+};
+
+const conn = MongoRunner.runMongod(options);
+const testDB = conn.getDB('test');
+var coll = testDB[jsTestName()];
+coll.drop();
+
+// Bulk insert documents to reduces roundtrips and make timeout on a slow machine less likely.
+const bulk = coll.initializeUnorderedBulkOp();
+const numDocs = 100;
+for (let i = 0; i < numDocs / 2; ++i) {
+ bulk.insert({foo: 0, bar: Math.floor(Math.random() * 3)});
+ bulk.insert({foo: 1, bar: Math.floor(Math.random() * -2)});
+}
+assert.commandWorked(bulk.execute());
+
+// Note that toArray is necessary to guarantee the query finishes executing on the server (at
+// which point an entry is finally written to the queryStats store).
+coll.find({foo: {$eq: 0}}).toArray();
+coll.find({foo: {$eq: 1}}).toArray();
+
+// This command will return all queryStats store entires.
+const queryStatsResults = getQueryStatsFindCmd(testDB);
+// Assert there is only one entry.
+assert.eq(queryStatsResults.length, 1, queryStatsResults);
+const queryStatsEntry = queryStatsResults[0];
+assert.eq(queryStatsEntry.key.queryShape.cmdNs.db, "test");
+assert.eq(queryStatsEntry.key.queryShape.cmdNs.coll, jsTestName());
+assert.eq(queryStatsEntry.key.client.application.name, "MongoDB Shell");
+
+// Assert we update execution count for identically shaped queries.
+assert.eq(queryStatsEntry.metrics.execCount, 2);
+
+// Assert queryStats values are accurate for the two above queries.
+assert.eq(queryStatsEntry.metrics.docsReturned.sum, numDocs);
+assert.eq(queryStatsEntry.metrics.docsReturned.min, numDocs / 2);
+assert.eq(queryStatsEntry.metrics.docsReturned.max, numDocs / 2);
+
+// The total size of documents in the collection should ensure that the queries in this test can
+// be executed without requiring multiple batches, but we verify that by looking at the
+// timestamps.
+assert.eq(queryStatsEntry.metrics.firstResponseExecMicros, queryStatsEntry.metrics.totalExecMicros);
+verifyMetrics(queryStatsResults);
+
+MongoRunner.stopMongod(conn);
+}());