Skip to content

Commit 962776a

Browse files
logaretmclaude
andcommitted
feat: add mongoose:cursor:next TracingChannel for cursor iteration
Adds a dedicated `mongoose:cursor:next` channel that traces each `next()` call on both QueryCursor and AggregationCursor. Kept separate from `mongoose:query` so APMs can subscribe independently and a future `mongoose:cursor` parent span channel won't conflict. Also refactors tracing.js: bulletproof dc import with process guard and try/catch, and a createTracedChannel factory to eliminate duplication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 31b6907 commit 962776a

4 files changed

Lines changed: 206 additions & 26 deletions

File tree

lib/cursor/aggregationCursor.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const eachAsync = require('../helpers/cursor/eachAsync');
1010
const immediate = require('../helpers/immediate');
1111
const kareem = require('kareem');
1212
const util = require('util');
13+
const { traceCursorNext } = require('../tracing');
1314

1415
/**
1516
* An AggregationCursor is a concurrency primitive for processing aggregation
@@ -277,14 +278,28 @@ AggregationCursor.prototype.next = async function next() {
277278
if (typeof arguments[0] === 'function') {
278279
throw new MongooseError('AggregationCursor.prototype.next() no longer accepts a callback');
279280
}
280-
return new Promise((resolve, reject) => {
281-
_next(this, (err, res) => {
282-
if (err != null) {
283-
return reject(err);
284-
}
285-
resolve(res);
281+
const _this = this;
282+
const model = this.agg._model;
283+
return traceCursorNext(function maybeTracedAggCursorNext() {
284+
return new Promise((resolve, reject) => {
285+
_next(_this, (err, res) => {
286+
if (err != null) {
287+
return reject(err);
288+
}
289+
resolve(res);
290+
});
286291
});
287-
});
292+
}, () => ({
293+
operation: 'aggregate',
294+
collection: model?.collection?.name,
295+
database: model?.db?.name,
296+
serverAddress: model?.db?.host,
297+
serverPort: model?.db?.port,
298+
args: {
299+
pipeline: _this.agg._pipeline,
300+
options: _this.agg.options
301+
}
302+
}));
288303
};
289304

290305
/**

lib/cursor/queryCursor.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const kareem = require('kareem');
1212
const immediate = require('../helpers/immediate');
1313
const { once } = require('events');
1414
const util = require('util');
15+
const { traceCursorNext } = require('../tracing');
1516

1617
/**
1718
* A QueryCursor is a concurrency primitive for processing query results
@@ -310,14 +311,27 @@ QueryCursor.prototype.next = async function next() {
310311
if (this._closed) {
311312
throw new MongooseError('Cannot call `next()` on a closed cursor');
312313
}
313-
return new Promise((resolve, reject) => {
314-
_next(this, function(error, doc) {
315-
if (error) {
316-
return reject(error);
317-
}
318-
resolve(doc);
314+
const _this = this;
315+
return traceCursorNext(function maybeTracedQueryCursorNext() {
316+
return new Promise((resolve, reject) => {
317+
_next(_this, function(error, doc) {
318+
if (error) {
319+
return reject(error);
320+
}
321+
resolve(doc);
322+
});
319323
});
320-
});
324+
}, () => ({
325+
operation: _this.query.op || 'find',
326+
collection: _this.query.mongooseCollection.name,
327+
database: _this.model.db?.name,
328+
serverAddress: _this.model.db?.host,
329+
serverPort: _this.model.db?.port,
330+
args: {
331+
filter: _this.query.getFilter(),
332+
options: _this.query._mongooseOptions
333+
}
334+
}));
321335
};
322336

323337
/**

lib/tracing.js

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
'use strict';
22

3-
const dc = process.getBuiltinModule('node:diagnostics_channel');
3+
let dc;
4+
try {
5+
dc = (typeof process !== 'undefined' && 'getBuiltinModule' in process)
6+
? process.getBuiltinModule('node:diagnostics_channel')
7+
: require('node:diagnostics_channel');
8+
} catch {
9+
// diagnostics_channel not available
10+
}
411

5-
const hasTracingChannel = typeof dc.tracingChannel === 'function';
12+
const hasTracingChannel = !!dc && typeof dc.tracingChannel === 'function';
613

714
function shouldTrace(channel) {
815
// Node 18 and Bun don't expose hasSubscribers on TracingChannel
@@ -12,21 +19,29 @@ function shouldTrace(channel) {
1219

1320
const noop = () => {};
1421

15-
const channel = hasTracingChannel
16-
? dc.tracingChannel('mongoose:query')
17-
: undefined;
22+
function createTracedChannel(name) {
23+
const ch = hasTracingChannel ? dc.tracingChannel(name) : undefined;
24+
25+
function trace(fn, contextFactory) {
26+
if (!shouldTrace(ch)) {
27+
return fn();
28+
}
1829

19-
function trace(fn, contextFactory) {
20-
if (shouldTrace(channel)) {
21-
const traced = channel.tracePromise(fn, contextFactory());
30+
const traced = ch.tracePromise(fn, contextFactory());
2231
traced.catch(noop);
2332
return traced;
2433
}
25-
return fn();
34+
35+
return { channel: ch, trace };
2636
}
2737

38+
const query = createTracedChannel('mongoose:query');
39+
const cursorNext = createTracedChannel('mongoose:cursor:next');
40+
2841
module.exports = {
29-
channel,
30-
trace,
42+
channel: query.channel,
43+
cursorNextChannel: cursorNext.channel,
44+
trace: query.trace,
45+
traceCursorNext: cursorNext.trace,
3146
shouldTrace
3247
};

test/tracing.test.js

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const assert = require('assert');
44
const mongoose = require('../index');
55

6-
const { channel } = require('../lib/tracing');
6+
const { channel, cursorNextChannel } = require('../lib/tracing');
77

88
describe('TracingChannel', function() {
99
let conn;
@@ -383,6 +383,131 @@ describe('TracingChannel', function() {
383383
});
384384
});
385385

386+
describe('cursor:next operations', function() {
387+
it('fires start and asyncEnd for query cursor next()', async function() {
388+
await Test.create([
389+
{ name: 'cursor1', age: 10 },
390+
{ name: 'cursor2', age: 20 }
391+
]);
392+
393+
const events = [];
394+
const handlers = {
395+
start(ctx) { events.push({ event: 'start', ...ctx }); },
396+
end() { events.push({ event: 'end' }); },
397+
asyncStart(ctx) { events.push({ event: 'asyncStart', result: ctx.result }); },
398+
asyncEnd(ctx) { events.push({ event: 'asyncEnd', result: ctx.result }); },
399+
error(ctx) { events.push({ event: 'error', error: ctx.error }); }
400+
};
401+
402+
cursorNextChannel.subscribe(handlers);
403+
try {
404+
const cursor = Test.find({ name: /^cursor/ }).sort({ name: 1 }).cursor();
405+
const doc1 = await cursor.next();
406+
const doc2 = await cursor.next();
407+
const doc3 = await cursor.next();
408+
409+
assert.strictEqual(doc1.name, 'cursor1');
410+
assert.strictEqual(doc2.name, 'cursor2');
411+
assert.strictEqual(doc3, null);
412+
413+
const starts = events.filter(e => e.event === 'start');
414+
assert.strictEqual(starts.length, 3, 'should fire start for each next() call');
415+
assert.strictEqual(starts[0].operation, 'find');
416+
assert.strictEqual(starts[0].collection, collectionName);
417+
assert.ok(starts[0].database);
418+
419+
const asyncEnds = events.filter(e => e.event === 'asyncEnd');
420+
assert.strictEqual(asyncEnds.length, 3, 'should fire asyncEnd for each next() call');
421+
} finally {
422+
cursorNextChannel.unsubscribe(handlers);
423+
}
424+
});
425+
426+
it('fires start and asyncEnd for aggregate cursor next()', async function() {
427+
await Test.create([
428+
{ name: 'agg-cursor1', age: 10 },
429+
{ name: 'agg-cursor2', age: 20 }
430+
]);
431+
432+
const events = [];
433+
const handlers = {
434+
start(ctx) { events.push({ event: 'start', ...ctx }); },
435+
end() { events.push({ event: 'end' }); },
436+
asyncStart() {},
437+
asyncEnd(ctx) { events.push({ event: 'asyncEnd', result: ctx.result }); },
438+
error() {}
439+
};
440+
441+
cursorNextChannel.subscribe(handlers);
442+
try {
443+
const cursor = Test.aggregate([
444+
{ $match: { name: /^agg-cursor/ } },
445+
{ $sort: { name: 1 } }
446+
]).cursor();
447+
448+
const doc1 = await cursor.next();
449+
const doc2 = await cursor.next();
450+
const doc3 = await cursor.next();
451+
452+
assert.strictEqual(doc1.name, 'agg-cursor1');
453+
assert.strictEqual(doc2.name, 'agg-cursor2');
454+
assert.strictEqual(doc3, null);
455+
456+
const starts = events.filter(e => e.event === 'start');
457+
assert.strictEqual(starts.length, 3, 'should fire start for each next() call');
458+
assert.strictEqual(starts[0].operation, 'aggregate');
459+
assert.strictEqual(starts[0].collection, collectionName);
460+
assert.ok(starts[0].database);
461+
assert.ok(Array.isArray(starts[0].args.pipeline));
462+
} finally {
463+
cursorNextChannel.unsubscribe(handlers);
464+
}
465+
});
466+
467+
it('fires error event on cursor next() failure', async function() {
468+
const events = [];
469+
const handlers = {
470+
start() { events.push({ event: 'start' }); },
471+
end() {},
472+
asyncStart() {},
473+
asyncEnd() {},
474+
error(ctx) { events.push({ event: 'error', error: ctx.error }); }
475+
};
476+
477+
cursorNextChannel.subscribe(handlers);
478+
try {
479+
const cursor = Test.find({ $invalidOperator: true }).cursor();
480+
await cursor.next().catch(() => {});
481+
482+
assert.ok(events.some(e => e.event === 'start'), 'start should fire');
483+
assert.ok(events.some(e => e.event === 'error'), 'error should fire');
484+
} finally {
485+
cursorNextChannel.unsubscribe(handlers);
486+
}
487+
});
488+
489+
it('does not fire cursor:next events when using regular find()', async function() {
490+
await Test.create({ name: 'no-cursor', age: 5 });
491+
492+
const events = [];
493+
const handlers = {
494+
start() { events.push({ event: 'start' }); },
495+
end() {},
496+
asyncStart() {},
497+
asyncEnd() {},
498+
error() {}
499+
};
500+
501+
cursorNextChannel.subscribe(handlers);
502+
try {
503+
await Test.find({ name: 'no-cursor' });
504+
assert.strictEqual(events.length, 0, 'cursor:next should not fire for regular find()');
505+
} finally {
506+
cursorNextChannel.unsubscribe(handlers);
507+
}
508+
});
509+
});
510+
386511
describe('zero-cost when no subscribers', function() {
387512
it('operations work without any subscribers', async function() {
388513
const doc = new Test({ name: 'no-sub', age: 5 });
@@ -401,5 +526,16 @@ describe('TracingChannel', function() {
401526

402527
await Test.deleteMany({ name: /^no-sub/ });
403528
});
529+
530+
it('cursor next() works without any subscribers', async function() {
531+
await Test.create({ name: 'no-sub-cursor', age: 5 });
532+
533+
const cursor = Test.find({ name: 'no-sub-cursor' }).cursor();
534+
const doc = await cursor.next();
535+
assert.strictEqual(doc.name, 'no-sub-cursor');
536+
537+
const done = await cursor.next();
538+
assert.strictEqual(done, null);
539+
});
404540
});
405541
});

0 commit comments

Comments
 (0)