jxp 4.1.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.sample +14 -0
- package/dist/bin/index_audit.d.ts +3 -0
- package/dist/bin/index_audit.d.ts.map +1 -0
- package/dist/bin/index_audit.js +66 -0
- package/dist/bin/index_audit.js.map +1 -0
- package/dist/bin/server.d.ts.map +1 -1
- package/dist/bin/server.js +6 -1
- package/dist/bin/server.js.map +1 -1
- package/dist/libs/builtin_models.d.ts +21 -0
- package/dist/libs/builtin_models.d.ts.map +1 -0
- package/dist/libs/builtin_models.js +125 -0
- package/dist/libs/builtin_models.js.map +1 -0
- package/dist/libs/docs-auth.d.ts.map +1 -1
- package/dist/libs/docs-auth.js +3 -1
- package/dist/libs/docs-auth.js.map +1 -1
- package/dist/libs/docs.js +32 -2
- package/dist/libs/docs.js.map +1 -1
- package/dist/libs/groups.js +3 -4
- package/dist/libs/groups.js.map +1 -1
- package/dist/libs/index_diagnostics.d.ts +144 -0
- package/dist/libs/index_diagnostics.d.ts.map +1 -0
- package/dist/libs/index_diagnostics.js +671 -0
- package/dist/libs/index_diagnostics.js.map +1 -0
- package/dist/libs/jxp.d.ts.map +1 -1
- package/dist/libs/jxp.js +94 -25
- package/dist/libs/jxp.js.map +1 -1
- package/dist/libs/load-config.d.ts +2 -0
- package/dist/libs/load-config.d.ts.map +1 -1
- package/dist/libs/load-config.js +10 -0
- package/dist/libs/load-config.js.map +1 -1
- package/dist/libs/login.js +3 -8
- package/dist/libs/login.js.map +1 -1
- package/dist/libs/parse_byte_size.d.ts +10 -0
- package/dist/libs/parse_byte_size.d.ts.map +1 -0
- package/dist/libs/parse_byte_size.js +80 -0
- package/dist/libs/parse_byte_size.js.map +1 -0
- package/dist/libs/query_limits.js +99 -13
- package/dist/libs/query_limits.js.map +1 -1
- package/dist/libs/schema.js +2 -2
- package/dist/libs/schema.js.map +1 -1
- package/dist/libs/security.js +7 -11
- package/dist/libs/security.js.map +1 -1
- package/dist/libs/setup.js +3 -4
- package/dist/libs/setup.js.map +1 -1
- package/dist/libs/startup.d.ts +4 -0
- package/dist/libs/startup.d.ts.map +1 -1
- package/dist/libs/startup.js +17 -0
- package/dist/libs/startup.js.map +1 -1
- package/dist/models/indexquerylog_model.d.ts +19 -0
- package/dist/models/indexquerylog_model.d.ts.map +1 -0
- package/dist/models/indexquerylog_model.js +32 -0
- package/dist/models/indexquerylog_model.js.map +1 -0
- package/dist/types/jxp-config.d.ts +22 -0
- package/dist/types/jxp-config.d.ts.map +1 -1
- package/dist/types/schema-fields.d.ts +4 -0
- package/dist/types/schema-fields.d.ts.map +1 -1
- package/docs/api.md +16 -2
- package/docs/changelog.md +66 -1
- package/docs/configuration.md +39 -0
- package/docs/index.md +1 -0
- package/docs/index_diagnostics.md +127 -0
- package/docs/schemas.md +3 -1
- package/mkdocs.yml +1 -0
- package/package.json +18 -7
- package/templates/assets/diagnostics.js +352 -0
- package/templates/diagnostics.pug +94 -0
- package/templates/index.pug +1 -0
- package/templates/sidebar.pug +8 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.INDEX_QUERY_LOG_MODEL_NAME = exports.SYNC_CONFIRM_PHRASE = void 0;
|
|
7
|
+
exports.shouldMonitorQuery = shouldMonitorQuery;
|
|
8
|
+
exports.summarizeFilter = summarizeFilter;
|
|
9
|
+
exports.extractScanStagePath = extractScanStagePath;
|
|
10
|
+
exports.formatScanStagePath = formatScanStagePath;
|
|
11
|
+
exports.hasCollScan = hasCollScan;
|
|
12
|
+
exports.classifyExplain = classifyExplain;
|
|
13
|
+
exports.auditModel = auditModel;
|
|
14
|
+
exports.auditAllModels = auditAllModels;
|
|
15
|
+
exports.syncAllModels = syncAllModels;
|
|
16
|
+
exports.loadModelsFromDir = loadModelsFromDir;
|
|
17
|
+
exports.wireQueryLogPersistence = wireQueryLogPersistence;
|
|
18
|
+
exports.resolveIndexDiagnosticsConfig = resolveIndexDiagnosticsConfig;
|
|
19
|
+
exports.setQueryLogModel = setQueryLogModel;
|
|
20
|
+
exports.setPersistQueries = setPersistQueries;
|
|
21
|
+
exports.getQueryMonitorBuffer = getQueryMonitorBuffer;
|
|
22
|
+
exports.getQueryMonitorStatus = getQueryMonitorStatus;
|
|
23
|
+
exports.listQueryLogs = listQueryLogs;
|
|
24
|
+
exports.getQueryMonitorConfig = getQueryMonitorConfig;
|
|
25
|
+
exports.registerQueryIndexMonitor = registerQueryIndexMonitor;
|
|
26
|
+
exports.getCachedIndexAudit = getCachedIndexAudit;
|
|
27
|
+
exports.formatAuditReportHuman = formatAuditReportHuman;
|
|
28
|
+
const mongoose_1 = __importDefault(require("mongoose"));
|
|
29
|
+
exports.SYNC_CONFIRM_PHRASE = "DROP_EXTRA_INDEXES";
|
|
30
|
+
exports.INDEX_QUERY_LOG_MODEL_NAME = "IndexQueryLog";
|
|
31
|
+
const READ_QUERY_OPS = ["find", "findOne", "count", "countDocuments", "distinct"];
|
|
32
|
+
const MONITOR_EXCLUDED_MODELS = new Set([exports.INDEX_QUERY_LOG_MODEL_NAME]);
|
|
33
|
+
/** Set on queries run by the monitor itself so post hooks do not re-enter. */
|
|
34
|
+
const INDEX_DIAG_INTERNAL_EXPLAIN = "_indexDiagExplain";
|
|
35
|
+
let explainMonitorDepth = 0;
|
|
36
|
+
/** Whether a completed query should trigger sampled explain monitoring. */
|
|
37
|
+
function shouldMonitorQuery(query) {
|
|
38
|
+
if (explainMonitorDepth > 0)
|
|
39
|
+
return false;
|
|
40
|
+
const opts = query.options;
|
|
41
|
+
if (opts?.explain)
|
|
42
|
+
return false;
|
|
43
|
+
if (opts?.[INDEX_DIAG_INTERNAL_EXPLAIN])
|
|
44
|
+
return false;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
function envBool(name, fallback = false) {
|
|
48
|
+
const v = process.env[name];
|
|
49
|
+
if (v === undefined)
|
|
50
|
+
return fallback;
|
|
51
|
+
return v === "1" || v.toLowerCase() === "true";
|
|
52
|
+
}
|
|
53
|
+
function envFloat(name, fallback) {
|
|
54
|
+
const n = parseFloat(process.env[name] ?? "");
|
|
55
|
+
return Number.isFinite(n) ? n : fallback;
|
|
56
|
+
}
|
|
57
|
+
function envInt(name, fallback) {
|
|
58
|
+
const n = parseInt(process.env[name] ?? "", 10);
|
|
59
|
+
return Number.isFinite(n) ? n : fallback;
|
|
60
|
+
}
|
|
61
|
+
function truncate(value, max = 120) {
|
|
62
|
+
const oneLine = value.replace(/[\r\n]+/g, " ").trim();
|
|
63
|
+
if (oneLine.length <= max)
|
|
64
|
+
return oneLine;
|
|
65
|
+
return `${oneLine.slice(0, max - 1)}…`;
|
|
66
|
+
}
|
|
67
|
+
function summarizeFilter(filter) {
|
|
68
|
+
if (filter === undefined || filter === null)
|
|
69
|
+
return "{}";
|
|
70
|
+
try {
|
|
71
|
+
return truncate(JSON.stringify(filter));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return "[unserializable]";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Stages that actually read from the collection (not plan wrappers like PROJECTION_*). */
|
|
78
|
+
const DATA_ACCESS_STAGES = new Set([
|
|
79
|
+
"COLLSCAN",
|
|
80
|
+
"IXSCAN",
|
|
81
|
+
"FETCH",
|
|
82
|
+
"IDHACK",
|
|
83
|
+
"DISTINCT_SCAN",
|
|
84
|
+
"TEXT",
|
|
85
|
+
"COUNT_SCAN",
|
|
86
|
+
"COUNT",
|
|
87
|
+
"SUBPLAN",
|
|
88
|
+
]);
|
|
89
|
+
function isDataAccessStage(name) {
|
|
90
|
+
return DATA_ACCESS_STAGES.has(name);
|
|
91
|
+
}
|
|
92
|
+
function stageName(stage) {
|
|
93
|
+
if (!stage || typeof stage !== "object")
|
|
94
|
+
return undefined;
|
|
95
|
+
const s = stage;
|
|
96
|
+
if (typeof s.stage === "string")
|
|
97
|
+
return s.stage;
|
|
98
|
+
if (s.inputStage)
|
|
99
|
+
return stageName(s.inputStage);
|
|
100
|
+
if (Array.isArray(s.inputStages) && s.inputStages[0])
|
|
101
|
+
return stageName(s.inputStages[0]);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
/** Deepest data-access stages from collection inward (e.g. IXSCAN → FETCH). */
|
|
105
|
+
function extractScanStagePath(stage) {
|
|
106
|
+
if (!stage || typeof stage !== "object")
|
|
107
|
+
return [];
|
|
108
|
+
const s = stage;
|
|
109
|
+
const name = typeof s.stage === "string" ? s.stage : undefined;
|
|
110
|
+
const childPaths = [];
|
|
111
|
+
if (s.inputStage)
|
|
112
|
+
childPaths.push(extractScanStagePath(s.inputStage));
|
|
113
|
+
if (Array.isArray(s.inputStages)) {
|
|
114
|
+
for (const st of s.inputStages)
|
|
115
|
+
childPaths.push(extractScanStagePath(st));
|
|
116
|
+
}
|
|
117
|
+
if (s.shards && typeof s.shards === "object") {
|
|
118
|
+
for (const shard of Object.values(s.shards)) {
|
|
119
|
+
if (shard && typeof shard === "object") {
|
|
120
|
+
const es = shard.executionStages;
|
|
121
|
+
if (es)
|
|
122
|
+
childPaths.push(extractScanStagePath(es));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const deepest = childPaths.reduce((best, cur) => (cur.length > best.length ? cur : best), []);
|
|
127
|
+
if (name && isDataAccessStage(name)) {
|
|
128
|
+
return [...deepest, name];
|
|
129
|
+
}
|
|
130
|
+
return deepest;
|
|
131
|
+
}
|
|
132
|
+
function formatScanStagePath(path) {
|
|
133
|
+
if (!path.length)
|
|
134
|
+
return undefined;
|
|
135
|
+
return path.join(" → ");
|
|
136
|
+
}
|
|
137
|
+
function scanStageFromExplain(rootStage) {
|
|
138
|
+
return formatScanStagePath(extractScanStagePath(rootStage)) ?? stageName(rootStage);
|
|
139
|
+
}
|
|
140
|
+
function hasCollScan(stage) {
|
|
141
|
+
if (!stage || typeof stage !== "object")
|
|
142
|
+
return false;
|
|
143
|
+
const s = stage;
|
|
144
|
+
if (s.stage === "COLLSCAN")
|
|
145
|
+
return true;
|
|
146
|
+
if (s.inputStage && hasCollScan(s.inputStage))
|
|
147
|
+
return true;
|
|
148
|
+
if (Array.isArray(s.inputStages)) {
|
|
149
|
+
return s.inputStages.some((st) => hasCollScan(st));
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function getExecutionStats(explain) {
|
|
154
|
+
const stats = explain.executionStats;
|
|
155
|
+
if (stats && typeof stats === "object")
|
|
156
|
+
return stats;
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
function classifyExplain(explain, options = {}) {
|
|
160
|
+
const minDocsExamined = options.minDocsExamined ?? 50;
|
|
161
|
+
const docsExaminedRatio = options.docsExaminedRatio ?? 10;
|
|
162
|
+
const smallCollectionThreshold = options.smallCollectionThreshold ?? 1000;
|
|
163
|
+
if (options.collectionDocCount !== undefined &&
|
|
164
|
+
options.collectionDocCount < smallCollectionThreshold) {
|
|
165
|
+
return { severity: "ignore", reason: "small_collection" };
|
|
166
|
+
}
|
|
167
|
+
const stats = getExecutionStats(explain);
|
|
168
|
+
if (!stats) {
|
|
169
|
+
return { severity: "ignore", reason: "no_execution_stats" };
|
|
170
|
+
}
|
|
171
|
+
const nReturned = Number(stats.nReturned ?? 0);
|
|
172
|
+
const totalDocsExamined = Number(stats.totalDocsExamined ?? 0);
|
|
173
|
+
const totalKeysExamined = Number(stats.totalKeysExamined ?? 0);
|
|
174
|
+
const millis = Number(stats.executionTimeMillis ?? 0);
|
|
175
|
+
const rootStage = stats.executionStages ?? explain.queryPlanner;
|
|
176
|
+
const stage = scanStageFromExplain(rootStage);
|
|
177
|
+
const collScan = hasCollScan(rootStage);
|
|
178
|
+
if (collScan && totalDocsExamined >= minDocsExamined) {
|
|
179
|
+
const ratio = totalDocsExamined / Math.max(nReturned, 1);
|
|
180
|
+
if (ratio >= docsExaminedRatio) {
|
|
181
|
+
return {
|
|
182
|
+
severity: "alert",
|
|
183
|
+
reason: "collection_scan",
|
|
184
|
+
stage,
|
|
185
|
+
totalDocsExamined,
|
|
186
|
+
nReturned,
|
|
187
|
+
totalKeysExamined,
|
|
188
|
+
millis,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!collScan &&
|
|
193
|
+
totalKeysExamined > 0 &&
|
|
194
|
+
totalDocsExamined >= minDocsExamined &&
|
|
195
|
+
totalDocsExamined > nReturned * docsExaminedRatio) {
|
|
196
|
+
return {
|
|
197
|
+
severity: "warn",
|
|
198
|
+
reason: "inefficient_index",
|
|
199
|
+
stage,
|
|
200
|
+
totalDocsExamined,
|
|
201
|
+
nReturned,
|
|
202
|
+
totalKeysExamined,
|
|
203
|
+
millis,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (collScan) {
|
|
207
|
+
return {
|
|
208
|
+
severity: "warn",
|
|
209
|
+
reason: "collection_scan_below_threshold",
|
|
210
|
+
stage,
|
|
211
|
+
totalDocsExamined,
|
|
212
|
+
nReturned,
|
|
213
|
+
totalKeysExamined,
|
|
214
|
+
millis,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
severity: "ok",
|
|
219
|
+
stage,
|
|
220
|
+
totalDocsExamined,
|
|
221
|
+
nReturned,
|
|
222
|
+
totalKeysExamined,
|
|
223
|
+
millis,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function formatSchemaIndex(entry) {
|
|
227
|
+
const [keys, options] = entry;
|
|
228
|
+
const out = { keys };
|
|
229
|
+
if (options && Object.keys(options).length)
|
|
230
|
+
out.options = options;
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
async function auditModel(model, opts) {
|
|
234
|
+
const modelName = model.modelName;
|
|
235
|
+
const collection = model.collection.name;
|
|
236
|
+
const entry = {
|
|
237
|
+
modelName,
|
|
238
|
+
collection,
|
|
239
|
+
ok: true,
|
|
240
|
+
missing: [],
|
|
241
|
+
extra: [],
|
|
242
|
+
schemaIndexes: [],
|
|
243
|
+
dbIndexes: [],
|
|
244
|
+
};
|
|
245
|
+
try {
|
|
246
|
+
const schemaIndexes = model.schema.indexes();
|
|
247
|
+
entry.schemaIndexes = schemaIndexes.map(formatSchemaIndex);
|
|
248
|
+
const dbIndexes = await model.listIndexes();
|
|
249
|
+
entry.dbIndexes = dbIndexes.map((idx) => ({
|
|
250
|
+
name: String(idx.name),
|
|
251
|
+
key: idx.key,
|
|
252
|
+
...idx,
|
|
253
|
+
}));
|
|
254
|
+
const diff = await model.diffIndexes();
|
|
255
|
+
entry.missing = (diff.toCreate || []);
|
|
256
|
+
entry.extra = (diff.toDrop || []);
|
|
257
|
+
entry.ok = entry.missing.length === 0 && entry.extra.length === 0;
|
|
258
|
+
if (opts?.includeUnused) {
|
|
259
|
+
try {
|
|
260
|
+
const stats = (await model.collection
|
|
261
|
+
.aggregate([{ $indexStats: {} }])
|
|
262
|
+
.toArray());
|
|
263
|
+
entry.unused = stats
|
|
264
|
+
.filter((s) => s.name !== "_id_" && (s.accesses?.ops ?? 0) === 0)
|
|
265
|
+
.map((s) => ({ name: s.name, accesses: s.accesses?.ops ?? 0 }));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
entry.unused = [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
entry.ok = false;
|
|
274
|
+
entry.error = err instanceof Error ? err.message : String(err);
|
|
275
|
+
}
|
|
276
|
+
return entry;
|
|
277
|
+
}
|
|
278
|
+
async function auditAllModels(models, opts) {
|
|
279
|
+
const collections = [];
|
|
280
|
+
const names = Object.keys(models).sort();
|
|
281
|
+
for (const name of names) {
|
|
282
|
+
collections.push(await auditModel(models[name], opts));
|
|
283
|
+
}
|
|
284
|
+
const withMissing = collections.filter((c) => c.missing.length > 0).length;
|
|
285
|
+
const withExtra = collections.filter((c) => c.extra.length > 0).length;
|
|
286
|
+
const errors = collections.filter((c) => c.error).length;
|
|
287
|
+
return {
|
|
288
|
+
generatedAt: new Date().toISOString(),
|
|
289
|
+
collections,
|
|
290
|
+
summary: {
|
|
291
|
+
total: collections.length,
|
|
292
|
+
ok: collections.filter((c) => c.ok).length,
|
|
293
|
+
withMissing,
|
|
294
|
+
withExtra,
|
|
295
|
+
errors,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
async function syncAllModels(models, opts) {
|
|
300
|
+
if (opts.confirm !== exports.SYNC_CONFIRM_PHRASE) {
|
|
301
|
+
throw new Error(`syncIndexes requires confirm="${exports.SYNC_CONFIRM_PHRASE}" (creates missing indexes and drops extras not in schema)`);
|
|
302
|
+
}
|
|
303
|
+
const results = [];
|
|
304
|
+
const names = Object.keys(models).sort();
|
|
305
|
+
for (const name of names) {
|
|
306
|
+
const model = models[name];
|
|
307
|
+
try {
|
|
308
|
+
const before = await model.diffIndexes();
|
|
309
|
+
await model.syncIndexes();
|
|
310
|
+
results.push({
|
|
311
|
+
modelName: name,
|
|
312
|
+
created: (before.toCreate || []).map((k) => JSON.stringify(k)),
|
|
313
|
+
dropped: before.toDrop || [],
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
results.push({
|
|
318
|
+
modelName: name,
|
|
319
|
+
error: err instanceof Error ? err.message : String(err),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return results;
|
|
324
|
+
}
|
|
325
|
+
const builtin_models_1 = require("./builtin_models");
|
|
326
|
+
/** Load app MODEL_DIR plus jxp built-in models (app overrides built-ins). */
|
|
327
|
+
function loadModelsFromDir(modelDir) {
|
|
328
|
+
return (0, builtin_models_1.loadAllModels)(modelDir);
|
|
329
|
+
}
|
|
330
|
+
function wireQueryLogPersistence(models) {
|
|
331
|
+
const logModel = models.indexquerylog ||
|
|
332
|
+
Object.values(models).find((m) => m.modelName === exports.INDEX_QUERY_LOG_MODEL_NAME);
|
|
333
|
+
if (logModel) {
|
|
334
|
+
setQueryLogModel(logModel);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
setQueryLogModel(null);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function resolveIndexDiagnosticsConfig(config) {
|
|
341
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
342
|
+
const masterOn = config?.enabled ?? envBool("INDEX_DIAGNOSTICS_ENABLED", false);
|
|
343
|
+
let monitorOn;
|
|
344
|
+
if (config?.query_monitor?.enabled !== undefined) {
|
|
345
|
+
monitorOn = config.query_monitor.enabled;
|
|
346
|
+
}
|
|
347
|
+
else if (process.env.QUERY_INDEX_MONITOR !== undefined) {
|
|
348
|
+
monitorOn = envBool("QUERY_INDEX_MONITOR");
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
monitorOn = isProd ? masterOn : true;
|
|
352
|
+
}
|
|
353
|
+
const enabled = masterOn || monitorOn === true;
|
|
354
|
+
const queryDefaults = isProd
|
|
355
|
+
? {
|
|
356
|
+
enabled: false,
|
|
357
|
+
sample_rate: 0.02,
|
|
358
|
+
min_docs_examined: 100,
|
|
359
|
+
docs_examined_ratio: 10,
|
|
360
|
+
small_collection_threshold: 1000,
|
|
361
|
+
buffer_size: 200,
|
|
362
|
+
}
|
|
363
|
+
: {
|
|
364
|
+
enabled: true,
|
|
365
|
+
sample_rate: 1.0,
|
|
366
|
+
min_docs_examined: 20,
|
|
367
|
+
docs_examined_ratio: 5,
|
|
368
|
+
small_collection_threshold: 500,
|
|
369
|
+
buffer_size: 200,
|
|
370
|
+
};
|
|
371
|
+
const qm = config?.query_monitor ?? {};
|
|
372
|
+
const query_monitor = {
|
|
373
|
+
enabled: enabled && monitorOn === true,
|
|
374
|
+
sample_rate: qm.sample_rate ?? envFloat("QUERY_INDEX_SAMPLE_RATE", queryDefaults.sample_rate),
|
|
375
|
+
min_docs_examined: qm.min_docs_examined ??
|
|
376
|
+
envInt("QUERY_INDEX_MIN_DOCS_EXAMINED", queryDefaults.min_docs_examined),
|
|
377
|
+
docs_examined_ratio: qm.docs_examined_ratio ??
|
|
378
|
+
envFloat("QUERY_INDEX_DOCS_EXAMINED_RATIO", queryDefaults.docs_examined_ratio),
|
|
379
|
+
small_collection_threshold: qm.small_collection_threshold ??
|
|
380
|
+
envInt("QUERY_INDEX_SMALL_COLLECTION_THRESHOLD", queryDefaults.small_collection_threshold),
|
|
381
|
+
buffer_size: qm.buffer_size ?? envInt("QUERY_INDEX_BUFFER_SIZE", queryDefaults.buffer_size),
|
|
382
|
+
};
|
|
383
|
+
return {
|
|
384
|
+
enabled,
|
|
385
|
+
query_monitor,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const queryBuffer = [];
|
|
389
|
+
let resolvedConfig = null;
|
|
390
|
+
let pluginRegistered = false;
|
|
391
|
+
let queryLogModel = null;
|
|
392
|
+
let persistQueries = true;
|
|
393
|
+
function setQueryLogModel(model) {
|
|
394
|
+
queryLogModel = model;
|
|
395
|
+
}
|
|
396
|
+
function setPersistQueries(enabled) {
|
|
397
|
+
persistQueries = enabled;
|
|
398
|
+
}
|
|
399
|
+
function getQueryMonitorBuffer() {
|
|
400
|
+
return [...queryBuffer];
|
|
401
|
+
}
|
|
402
|
+
function envWantsQueryMonitor() {
|
|
403
|
+
if (envBool("INDEX_DIAGNOSTICS_ENABLED", false))
|
|
404
|
+
return true;
|
|
405
|
+
if (process.env.QUERY_INDEX_MONITOR !== undefined) {
|
|
406
|
+
return envBool("QUERY_INDEX_MONITOR");
|
|
407
|
+
}
|
|
408
|
+
return process.env.NODE_ENV !== "production";
|
|
409
|
+
}
|
|
410
|
+
function getQueryMonitorStatus() {
|
|
411
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
412
|
+
const cfg = resolvedConfig;
|
|
413
|
+
const diagnostics_enabled = cfg?.enabled ?? false;
|
|
414
|
+
const query_monitor_enabled = cfg?.query_monitor?.enabled ?? false;
|
|
415
|
+
const active = diagnostics_enabled && query_monitor_enabled;
|
|
416
|
+
const registration_missing = cfg === null && envWantsQueryMonitor();
|
|
417
|
+
const envSnapshot = {
|
|
418
|
+
QUERY_INDEX_MONITOR: process.env.QUERY_INDEX_MONITOR,
|
|
419
|
+
INDEX_DIAGNOSTICS_ENABLED: process.env.INDEX_DIAGNOSTICS_ENABLED,
|
|
420
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
421
|
+
};
|
|
422
|
+
if (active) {
|
|
423
|
+
return {
|
|
424
|
+
active: true,
|
|
425
|
+
is_production: isProd,
|
|
426
|
+
diagnostics_enabled,
|
|
427
|
+
query_monitor_enabled,
|
|
428
|
+
registration_missing: false,
|
|
429
|
+
env: envSnapshot,
|
|
430
|
+
env_hints: [],
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const env_hints = isProd
|
|
434
|
+
? [
|
|
435
|
+
{
|
|
436
|
+
name: "INDEX_DIAGNOSTICS_ENABLED",
|
|
437
|
+
value: "true",
|
|
438
|
+
comment: "Required in production",
|
|
439
|
+
},
|
|
440
|
+
{ name: "QUERY_INDEX_MONITOR", value: "true" },
|
|
441
|
+
{
|
|
442
|
+
name: "QUERY_INDEX_SAMPLE_RATE",
|
|
443
|
+
value: "0.02",
|
|
444
|
+
comment: "1–5% recommended",
|
|
445
|
+
},
|
|
446
|
+
]
|
|
447
|
+
: [{ name: "QUERY_INDEX_MONITOR", value: "true" }];
|
|
448
|
+
if (registration_missing) {
|
|
449
|
+
env_hints.unshift({
|
|
450
|
+
name: "(restart)",
|
|
451
|
+
value: "required",
|
|
452
|
+
comment: "Env is set but monitor was not registered at startup — restart after upgrading jxp",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
active: false,
|
|
457
|
+
is_production: isProd,
|
|
458
|
+
diagnostics_enabled,
|
|
459
|
+
query_monitor_enabled,
|
|
460
|
+
registration_missing,
|
|
461
|
+
env: envSnapshot,
|
|
462
|
+
env_hints,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
async function listQueryLogs(opts = {}) {
|
|
466
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
|
|
467
|
+
const skip = Math.max(opts.skip ?? 0, 0);
|
|
468
|
+
const config = getQueryMonitorConfig();
|
|
469
|
+
const monitor_status = getQueryMonitorStatus();
|
|
470
|
+
if (!queryLogModel || !persistQueries) {
|
|
471
|
+
const entries = getQueryMonitorBuffer()
|
|
472
|
+
.filter((e) => {
|
|
473
|
+
if (opts.severity && e.severity !== opts.severity)
|
|
474
|
+
return false;
|
|
475
|
+
if (opts.model_name && e.model !== opts.model_name)
|
|
476
|
+
return false;
|
|
477
|
+
return true;
|
|
478
|
+
})
|
|
479
|
+
.slice(skip, skip + limit);
|
|
480
|
+
return { config, monitor_status, entries, persisted: false };
|
|
481
|
+
}
|
|
482
|
+
const filter = {};
|
|
483
|
+
if (opts.severity)
|
|
484
|
+
filter.severity = opts.severity;
|
|
485
|
+
if (opts.model_name)
|
|
486
|
+
filter.model_name = opts.model_name;
|
|
487
|
+
const [docs, total] = await Promise.all([
|
|
488
|
+
queryLogModel
|
|
489
|
+
.find(filter)
|
|
490
|
+
.sort({ observed_at: -1 })
|
|
491
|
+
.skip(skip)
|
|
492
|
+
.limit(limit)
|
|
493
|
+
.lean(),
|
|
494
|
+
queryLogModel.countDocuments(filter),
|
|
495
|
+
]);
|
|
496
|
+
const entries = docs.map((doc) => {
|
|
497
|
+
const d = doc;
|
|
498
|
+
const observed = d.observed_at || d.createdAt;
|
|
499
|
+
return {
|
|
500
|
+
at: observed instanceof Date ? observed.toISOString() : String(observed ?? ""),
|
|
501
|
+
model: String(d.model_name ?? ""),
|
|
502
|
+
op: String(d.op ?? ""),
|
|
503
|
+
filterSummary: String(d.filter_summary ?? "{}"),
|
|
504
|
+
severity: d.severity,
|
|
505
|
+
reason: d.reason ? String(d.reason) : undefined,
|
|
506
|
+
stage: d.stage ? String(d.stage) : undefined,
|
|
507
|
+
totalDocsExamined: d.total_docs_examined,
|
|
508
|
+
nReturned: d.n_returned,
|
|
509
|
+
totalKeysExamined: d.total_keys_examined,
|
|
510
|
+
millis: d.millis,
|
|
511
|
+
id: d._id ? String(d._id) : undefined,
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
return { config, monitor_status, entries, persisted: true, total };
|
|
515
|
+
}
|
|
516
|
+
function getQueryMonitorConfig() {
|
|
517
|
+
return resolvedConfig?.query_monitor ?? null;
|
|
518
|
+
}
|
|
519
|
+
function shouldSample(cfg) {
|
|
520
|
+
if (!cfg.enabled || !cfg.query_monitor.enabled)
|
|
521
|
+
return false;
|
|
522
|
+
if (cfg.query_monitor.sample_rate >= 1)
|
|
523
|
+
return true;
|
|
524
|
+
return Math.random() < cfg.query_monitor.sample_rate;
|
|
525
|
+
}
|
|
526
|
+
function pushQueryEntry(entry, maxSize) {
|
|
527
|
+
queryBuffer.push(entry);
|
|
528
|
+
while (queryBuffer.length > maxSize) {
|
|
529
|
+
queryBuffer.shift();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async function persistQueryEntry(entry) {
|
|
533
|
+
if (!persistQueries || !queryLogModel)
|
|
534
|
+
return;
|
|
535
|
+
if (entry.severity !== "alert" && entry.severity !== "warn")
|
|
536
|
+
return;
|
|
537
|
+
try {
|
|
538
|
+
await queryLogModel.create({
|
|
539
|
+
model_name: entry.model,
|
|
540
|
+
op: entry.op,
|
|
541
|
+
filter_summary: entry.filterSummary,
|
|
542
|
+
severity: entry.severity,
|
|
543
|
+
reason: entry.reason,
|
|
544
|
+
stage: entry.stage,
|
|
545
|
+
total_docs_examined: entry.totalDocsExamined,
|
|
546
|
+
n_returned: entry.nReturned,
|
|
547
|
+
total_keys_examined: entry.totalKeysExamined,
|
|
548
|
+
millis: entry.millis,
|
|
549
|
+
observed_at: new Date(entry.at),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// persistence must not affect API requests
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async function runExplainMonitor(query, op, cfg) {
|
|
557
|
+
if (!shouldMonitorQuery(query))
|
|
558
|
+
return;
|
|
559
|
+
const model = query.model;
|
|
560
|
+
if (!model)
|
|
561
|
+
return;
|
|
562
|
+
if (MONITOR_EXCLUDED_MODELS.has(model.modelName))
|
|
563
|
+
return;
|
|
564
|
+
const qm = cfg.query_monitor;
|
|
565
|
+
let collectionDocCount;
|
|
566
|
+
explainMonitorDepth += 1;
|
|
567
|
+
try {
|
|
568
|
+
try {
|
|
569
|
+
const estimated = await model.estimatedDocumentCount();
|
|
570
|
+
collectionDocCount = estimated;
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
collectionDocCount = undefined;
|
|
574
|
+
}
|
|
575
|
+
const explainQuery = query.clone().explain("executionStats");
|
|
576
|
+
explainQuery.setOptions({ [INDEX_DIAG_INTERNAL_EXPLAIN]: true });
|
|
577
|
+
const explain = (await explainQuery);
|
|
578
|
+
const classification = classifyExplain(explain, {
|
|
579
|
+
minDocsExamined: qm.min_docs_examined,
|
|
580
|
+
docsExaminedRatio: qm.docs_examined_ratio,
|
|
581
|
+
smallCollectionThreshold: qm.small_collection_threshold,
|
|
582
|
+
collectionDocCount,
|
|
583
|
+
});
|
|
584
|
+
if (classification.severity === "ignore" || classification.severity === "ok")
|
|
585
|
+
return;
|
|
586
|
+
const entry = {
|
|
587
|
+
at: new Date().toISOString(),
|
|
588
|
+
model: model.modelName,
|
|
589
|
+
op,
|
|
590
|
+
filterSummary: summarizeFilter(query.getFilter()),
|
|
591
|
+
severity: classification.severity,
|
|
592
|
+
reason: classification.reason,
|
|
593
|
+
stage: classification.stage,
|
|
594
|
+
totalDocsExamined: classification.totalDocsExamined,
|
|
595
|
+
nReturned: classification.nReturned,
|
|
596
|
+
totalKeysExamined: classification.totalKeysExamined,
|
|
597
|
+
millis: classification.millis,
|
|
598
|
+
};
|
|
599
|
+
pushQueryEntry(entry, qm.buffer_size);
|
|
600
|
+
void persistQueryEntry(entry);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// explain failures must not affect API requests
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
explainMonitorDepth -= 1;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function attachQueryMonitorHooks(schema, cfg) {
|
|
610
|
+
for (const op of READ_QUERY_OPS) {
|
|
611
|
+
schema.post(op, function () {
|
|
612
|
+
if (!shouldSample(cfg))
|
|
613
|
+
return;
|
|
614
|
+
const query = this;
|
|
615
|
+
if (!shouldMonitorQuery(query))
|
|
616
|
+
return;
|
|
617
|
+
setImmediate(() => {
|
|
618
|
+
void runExplainMonitor(query, op, cfg);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Register mongoose plugin for sampled explain-based query monitoring.
|
|
625
|
+
* Call before models are loaded.
|
|
626
|
+
*/
|
|
627
|
+
function registerQueryIndexMonitor(config) {
|
|
628
|
+
resolvedConfig = resolveIndexDiagnosticsConfig(config);
|
|
629
|
+
if (!resolvedConfig.enabled || !resolvedConfig.query_monitor.enabled)
|
|
630
|
+
return;
|
|
631
|
+
if (pluginRegistered)
|
|
632
|
+
return;
|
|
633
|
+
pluginRegistered = true;
|
|
634
|
+
const cfg = resolvedConfig;
|
|
635
|
+
mongoose_1.default.plugin((schema) => {
|
|
636
|
+
attachQueryMonitorHooks(schema, cfg);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
let auditCache = null;
|
|
640
|
+
const AUDIT_CACHE_MS = 30_000;
|
|
641
|
+
async function getCachedIndexAudit(models, opts) {
|
|
642
|
+
const now = Date.now();
|
|
643
|
+
if (!opts?.refresh && auditCache && now - auditCache.at < AUDIT_CACHE_MS) {
|
|
644
|
+
return auditCache.report;
|
|
645
|
+
}
|
|
646
|
+
const report = await auditAllModels(models, opts);
|
|
647
|
+
auditCache = { at: now, report };
|
|
648
|
+
return report;
|
|
649
|
+
}
|
|
650
|
+
function formatAuditReportHuman(report) {
|
|
651
|
+
const lines = [];
|
|
652
|
+
for (const c of report.collections) {
|
|
653
|
+
if (c.error) {
|
|
654
|
+
lines.push(`${c.modelName}: ERROR ${c.error}`);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (c.ok) {
|
|
658
|
+
lines.push(`${c.modelName}: OK`);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const parts = [`${c.modelName}:`];
|
|
662
|
+
if (c.missing.length)
|
|
663
|
+
parts.push(`MISSING ${c.missing.map((k) => JSON.stringify(k)).join(", ")}`);
|
|
664
|
+
if (c.extra.length)
|
|
665
|
+
parts.push(`EXTRA [${c.extra.join(", ")}]`);
|
|
666
|
+
lines.push(parts.join(" "));
|
|
667
|
+
}
|
|
668
|
+
lines.push(`--- summary: ${report.summary.ok}/${report.summary.total} OK, ${report.summary.withMissing} missing, ${report.summary.withExtra} extra, ${report.summary.errors} errors`);
|
|
669
|
+
return lines.join("\n");
|
|
670
|
+
}
|
|
671
|
+
//# sourceMappingURL=index_diagnostics.js.map
|