Skip to content

Commit 0e1e4c1

Browse files
committed
feat(mongoose): Add diagnostics_channel integration for mongoose >= 9.7
Subscribe to mongoose 9.7's native diagnostics_channel tracing channels via bindTracingChannelToSpan, emitting stable OTel DB semconv. Narrow the vendored OTel patcher to < 9.7.0 to avoid double instrumentation.
1 parent 8b8480c commit 0e1e4c1

10 files changed

Lines changed: 740 additions & 7 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as Sentry from '@sentry/node';
2+
import mongoose from 'mongoose';
3+
4+
async function run() {
5+
await mongoose.connect(process.env.MONGO_URL || '');
6+
7+
const BlogPostSchema = new mongoose.Schema({
8+
title: String,
9+
body: String,
10+
date: Date,
11+
});
12+
13+
const BlogPost = mongoose.model('BlogPost', BlogPostSchema);
14+
15+
await Sentry.startSpan(
16+
{
17+
name: 'Test Transaction',
18+
op: 'transaction',
19+
},
20+
async () => {
21+
const post = new BlogPost({ title: 'Test', body: 'Test body', date: new Date() });
22+
await post.save();
23+
24+
// Filter with a real value, to assert it is redacted out of `db.query.text`.
25+
await BlogPost.findOne({ title: 'Test' });
26+
27+
await BlogPost.aggregate([{ $match: { title: 'Test' } }]);
28+
29+
await BlogPost.insertMany([
30+
{ title: 'Insert1', body: 'b', date: new Date() },
31+
{ title: 'Insert2', body: 'b', date: new Date() },
32+
]);
33+
34+
await BlogPost.bulkWrite([
35+
{ insertOne: { document: { title: 'Bulk1', body: 'b', date: new Date() } } },
36+
{ insertOne: { document: { title: 'Bulk2', body: 'b', date: new Date() } } },
37+
]);
38+
39+
// Drive a cursor to exercise the `mongoose:cursor:next` channel.
40+
const cursor = BlogPost.find().cursor();
41+
for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) {
42+
// iterate
43+
}
44+
},
45+
);
46+
}
47+
48+
run();
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { MongoMemoryServer } from 'mongodb-memory-server-global';
2+
import { afterAll, beforeAll, expect } from 'vitest';
3+
import { conditionalTest } from '../../../utils';
4+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
5+
6+
// mongoose >= 9.7.0 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes
7+
// to those channels (`subscribeMongooseDiagnosticChannels`) instead of monkey-patching. This suite
8+
// pins `^9.7` and asserts the diagnostics-channel path: stable OTel DB semconv attributes, redacted
9+
// query text, span relationships, and that the legacy IITM patcher does NOT also fire (no double
10+
// instrumentation). mongoose 9 requires Node >=20.19, so this suite is skipped on older Node.
11+
conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => {
12+
let mongoServer: MongoMemoryServer;
13+
14+
beforeAll(async () => {
15+
mongoServer = await MongoMemoryServer.create();
16+
process.env.MONGO_URL = mongoServer.getUri();
17+
}, 30000);
18+
19+
afterAll(async () => {
20+
if (mongoServer) {
21+
await mongoServer.stop();
22+
}
23+
cleanupChildProcesses();
24+
});
25+
26+
const expectedSpan = (operation: string, extraData: Record<string, unknown> = {}) =>
27+
expect.objectContaining({
28+
data: expect.objectContaining({
29+
'db.system.name': 'mongodb',
30+
'db.namespace': 'test',
31+
'db.collection.name': 'blogposts',
32+
'db.operation.name': operation,
33+
'server.address': expect.any(String),
34+
'server.port': expect.any(Number),
35+
...extraData,
36+
}),
37+
description: `mongoose.blogposts.${operation}`,
38+
op: 'db',
39+
origin: 'auto.db.mongoose.diagnostic_channel',
40+
});
41+
42+
const EXPECTED_TRANSACTION = {
43+
transaction: 'Test Transaction',
44+
spans: expect.arrayContaining([
45+
expectedSpan('save'),
46+
// filter values are redacted out of `db.query.text`
47+
expectedSpan('findOne', { 'db.query.text': '{"title":"?"}' }),
48+
expectedSpan('aggregate', { 'db.query.text': '[{"$match":{"title":"?"}}]' }),
49+
expectedSpan('insertMany', { 'db.operation.batch.size': 2 }),
50+
expectedSpan('bulkWrite', { 'db.operation.batch.size': 2 }),
51+
// a cursor iteration emits a span per `.next()` via the `mongoose:cursor:next` channel
52+
expectedSpan('find'),
53+
]),
54+
};
55+
56+
createEsmAndCjsTests(
57+
__dirname,
58+
'scenario.mjs',
59+
'instrument.mjs',
60+
(createTestRunner, test) => {
61+
test('subscribes to mongoose >= 9.7 diagnostics channels with stable semconv attributes', async () => {
62+
await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
63+
});
64+
65+
test('does not double-instrument: the legacy IITM mongoose patcher does not fire on 9.7', async () => {
66+
await createTestRunner()
67+
.expect({
68+
transaction: event => {
69+
const spans = event.spans || [];
70+
// The monkey-patch path (origin `auto.db.otel.mongoose`) must be inactive on 9.7+.
71+
expect(spans.find(span => span.origin === 'auto.db.otel.mongoose')).toBeUndefined();
72+
// ...while the diagnostics-channel path is active.
73+
expect(spans.find(span => span.origin === 'auto.db.mongoose.diagnostic_channel')).toBeDefined();
74+
},
75+
})
76+
.start()
77+
.completed();
78+
});
79+
80+
test('never leaks raw filter values into db.query.text', async () => {
81+
await createTestRunner()
82+
.expect({
83+
transaction: event => {
84+
const spans = event.spans || [];
85+
for (const span of spans) {
86+
const queryText = span.data?.['db.query.text'];
87+
if (typeof queryText === 'string') {
88+
expect(queryText).not.toContain('Test');
89+
}
90+
}
91+
},
92+
})
93+
.start()
94+
.completed();
95+
});
96+
97+
test('nests the mongodb driver span under the mongoose channel span', async () => {
98+
await createTestRunner()
99+
.expect({
100+
transaction: event => {
101+
const spans = event.spans || [];
102+
const mongooseSave = spans.find(span => span.description === 'mongoose.blogposts.save');
103+
expect(mongooseSave).toBeDefined();
104+
// the underlying mongodb driver span must parent to the mongoose channel span,
105+
// proving the channel span is the active async context for the traced operation
106+
const driverChild = spans.find(
107+
span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo',
108+
);
109+
expect(driverChild).toBeDefined();
110+
},
111+
})
112+
.start()
113+
.completed();
114+
});
115+
},
116+
{ additionalDependencies: { mongoose: '^9.7' } },
117+
);
118+
});

dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { afterAll, beforeAll, expect } from 'vitest';
33
import { conditionalTest } from '../../../utils';
44
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
55

6-
// Pins mongoose 9 (top of our supported `>=5.9.7 <10` range) so the latest major is exercised
7-
// against a real mongoose. mongoose 9 requires Node >=20.19, so this suite is skipped on older Node.
6+
// Pins the highest mongoose 9 below 9.7, the top of the IITM patcher's `>=5.9.7 <9.7.0` range, so the
7+
// monkey-patch path is exercised against a real mongoose 9. mongoose >= 9.7 publishes via
8+
// diagnostics_channel and is covered by the `mongoose-tracing-channel` suite instead.
9+
// mongoose 9 requires Node >=20.19, so this suite is skipped on older Node.
810
conditionalTest({ min: 20 })('Mongoose v9 Test', () => {
911
let mongoServer: MongoMemoryServer;
1012

@@ -55,6 +57,6 @@ conditionalTest({ min: 20 })('Mongoose v9 Test', () => {
5557
await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
5658
});
5759
},
58-
{ additionalDependencies: { mongoose: '^9' } },
60+
{ additionalDependencies: { mongoose: '>=9 <9.7' } },
5961
);
6062
});

packages/node/src/integrations/tracing/mongoose/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { MongooseInstrumentation } from './vendored/mongoose';
22
import type { IntegrationFn } from '@sentry/core';
3-
import { defineIntegration } from '@sentry/core';
3+
import { defineIntegration, extendIntegration } from '@sentry/core';
44
import { generateInstrumentOnce } from '@sentry/node-core';
5+
import { mongooseChannelIntegration } from '@sentry/server-utils';
56

67
const INTEGRATION_NAME = 'Mongoose' as const;
78

89
export const instrumentMongoose = generateInstrumentOnce(INTEGRATION_NAME, () => new MongooseInstrumentation());
910

1011
const _mongooseIntegration = (() => {
11-
return {
12+
// The diagnostics_channel subscription (mongoose >= 9.7) lives in server-utils so it is shared
13+
// across server runtimes; we extend it here to also run the IITM-based patcher for mongoose < 9.7.
14+
return extendIntegration(mongooseChannelIntegration(), {
1215
name: INTEGRATION_NAME,
1316
setupOnce() {
1417
instrumentMongoose();
1518
},
16-
};
19+
});
1720
}) satisfies IntegrationFn;
1821

1922
/**

packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ export class MongooseInstrumentation extends InstrumentationBase<Instrumentation
107107
protected init(): InstrumentationModuleDefinition {
108108
const module = new InstrumentationNodeModuleDefinition(
109109
'mongoose',
110-
['>=5.9.7 <10'],
110+
// mongoose >= 9.7.0 publishes via diagnostics_channel and is instrumented by
111+
// `subscribeMongooseDiagnosticChannels` instead, so this IITM patcher must not
112+
// overlap it — otherwise every operation would emit two mongoose spans.
113+
['>=5.9.7 <9.7.0'],
111114
this.patch.bind(this),
112115
this.unpatch.bind(this),
113116
);

packages/server-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* @module
55
*/
66

7+
export { mongooseChannelIntegration } from './mongoose';
78
export {
89
IOREDIS_DC_CHANNEL_COMMAND,
910
IOREDIS_DC_CHANNEL_CONNECT,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { defineIntegration, type IntegrationFn } from '@sentry/core';
2+
import * as dc from 'node:diagnostics_channel';
3+
import { subscribeMongooseDiagnosticChannels } from './mongoose-dc-subscriber';
4+
5+
const _mongooseChannelIntegration = (() => {
6+
return {
7+
name: 'Mongoose',
8+
setupOnce() {
9+
// Bail on Node <= 18.18.0, where `tracingChannel` does not exist.
10+
if (!dc.tracingChannel) {
11+
return;
12+
}
13+
14+
// Subscribe to mongoose's native tracing channels (mongoose >= 9.7).
15+
// This is a no-op on versions that don't publish to the channels, so it is always safe to call.
16+
// `bindTracingChannelToSpan` (inside the subscriber) makes the span the active context via
17+
// `bindStore`, which needs the Sentry OTel context manager — `initOpenTelemetry()` registers
18+
// that after `setupOnce`, so defer a tick.
19+
void Promise.resolve().then(() => subscribeMongooseDiagnosticChannels(dc.tracingChannel));
20+
},
21+
};
22+
}) satisfies IntegrationFn;
23+
24+
/**
25+
* Auto-instrument the [mongoose](https://www.npmjs.com/package/mongoose) library via its native
26+
* `node:diagnostics_channel` tracing channels (mongoose >= 9.7).
27+
*
28+
* On older mongoose versions the channels are never published to, so this integration is inert and
29+
* the IITM-based patcher (gated to `< 9.7.0`) handles instrumentation instead.
30+
*/
31+
export const mongooseChannelIntegration = defineIntegration(_mongooseChannelIntegration);

0 commit comments

Comments
 (0)