Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as Sentry from '@sentry/node';
import mongoose from 'mongoose';

async function run() {
await mongoose.connect(process.env.MONGO_URL || '');

const BlogPostSchema = new mongoose.Schema({
title: String,
body: String,
date: Date,
});

const BlogPost = mongoose.model('BlogPost', BlogPostSchema);

await Sentry.startSpan(
{
name: 'Test Transaction',
op: 'transaction',
},
async () => {
const post = new BlogPost({ title: 'Test', body: 'Test body', date: new Date() });
await post.save();

// Filter with a real value, to assert it is redacted out of `db.query.text`.
await BlogPost.findOne({ title: 'Test' });

await BlogPost.aggregate([{ $match: { title: 'Test' } }]);

await BlogPost.insertMany([
{ title: 'Insert1', body: 'b', date: new Date() },
{ title: 'Insert2', body: 'b', date: new Date() },
]);

await BlogPost.bulkWrite([
{ insertOne: { document: { title: 'Bulk1', body: 'b', date: new Date() } } },
{ insertOne: { document: { title: 'Bulk2', body: 'b', date: new Date() } } },
]);

// Drive a cursor to exercise the `mongoose:cursor:next` channel.
const cursor = BlogPost.find().cursor();
for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) {
// iterate
}
},
);
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { MongoMemoryServer } from 'mongodb-memory-server-global';
import { afterAll, beforeAll, expect } from 'vitest';
import { conditionalTest } from '../../../utils';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

// mongoose >= 9.7.0 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes
// to those channels (`subscribeMongooseDiagnosticChannels`) instead of monkey-patching. This suite
// pins `^9.7` and asserts the diagnostics-channel path: stable OTel DB semconv attributes, redacted
// query text, span relationships, and that the legacy IITM patcher does NOT also fire (no double
// instrumentation). mongoose 9 requires Node >=20.19, so this suite is skipped on older Node.
conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => {
let mongoServer: MongoMemoryServer;

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
process.env.MONGO_URL = mongoServer.getUri();
}, 30000);

afterAll(async () => {
if (mongoServer) {
await mongoServer.stop();
}
cleanupChildProcesses();
});

const expectedSpan = (operation: string, extraData: Record<string, unknown> = {}) =>
expect.objectContaining({
data: expect.objectContaining({
'db.system.name': 'mongodb',
'db.namespace': 'test',
'db.collection.name': 'blogposts',
'db.operation.name': operation,
'server.address': expect.any(String),
'server.port': expect.any(Number),
...extraData,
}),
description: `mongoose.blogposts.${operation}`,
op: 'db',
origin: 'auto.db.mongoose.diagnostic_channel',
});

const EXPECTED_TRANSACTION = {
transaction: 'Test Transaction',
spans: expect.arrayContaining([
expectedSpan('save'),
// filter values are redacted out of `db.query.text`
expectedSpan('findOne', { 'db.query.text': '{"title":"?"}' }),
expectedSpan('aggregate', { 'db.query.text': '[{"$match":{"title":"?"}}]' }),
expectedSpan('insertMany', { 'db.operation.batch.size': 2 }),
expectedSpan('bulkWrite', { 'db.operation.batch.size': 2 }),
// a cursor iteration emits a span per `.next()` via the `mongoose:cursor:next` channel
expectedSpan('find'),
]),
};

createEsmAndCjsTests(
__dirname,
'scenario.mjs',
'instrument.mjs',
(createTestRunner, test) => {
test('subscribes to mongoose >= 9.7 diagnostics channels with stable semconv attributes', async () => {
await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
});

test('does not double-instrument: the legacy IITM mongoose patcher does not fire on 9.7', async () => {
await createTestRunner()
.expect({
transaction: event => {
const spans = event.spans || [];
// The monkey-patch path (origin `auto.db.otel.mongoose`) must be inactive on 9.7+.
expect(spans.find(span => span.origin === 'auto.db.otel.mongoose')).toBeUndefined();
// ...while the diagnostics-channel path is active.
expect(spans.find(span => span.origin === 'auto.db.mongoose.diagnostic_channel')).toBeDefined();
},
})
.start()
.completed();
});

test('never leaks raw filter values into db.query.text', async () => {
await createTestRunner()
.expect({
transaction: event => {
const spans = event.spans || [];
for (const span of spans) {
const queryText = span.data?.['db.query.text'];
if (typeof queryText === 'string') {
expect(queryText).not.toContain('Test');
}
}
},
})
.start()
.completed();
});

test('nests the mongodb driver span under the mongoose channel span', async () => {
await createTestRunner()
.expect({
transaction: event => {
const spans = event.spans || [];
const mongooseSave = spans.find(span => span.description === 'mongoose.blogposts.save');
expect(mongooseSave).toBeDefined();
// the underlying mongodb driver span must parent to the mongoose channel span,
// proving the channel span is the active async context for the traced operation
const driverChild = spans.find(
span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo',
);
expect(driverChild).toBeDefined();
},
})
.start()
.completed();
});
},
{ additionalDependencies: { mongoose: '^9.7' } },
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { afterAll, beforeAll, expect } from 'vitest';
import { conditionalTest } from '../../../utils';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

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

Expand Down Expand Up @@ -55,6 +57,6 @@ conditionalTest({ min: 20 })('Mongoose v9 Test', () => {
await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
});
},
{ additionalDependencies: { mongoose: '^9' } },
{ additionalDependencies: { mongoose: '>=9 <9.7' } },
);
});
9 changes: 6 additions & 3 deletions packages/node/src/integrations/tracing/mongoose/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { MongooseInstrumentation } from './vendored/mongoose';
import type { IntegrationFn } from '@sentry/core';
import { defineIntegration } from '@sentry/core';
import { defineIntegration, extendIntegration } from '@sentry/core';
import { generateInstrumentOnce } from '@sentry/node-core';
import { mongooseIntegration as mongooseChannelIntegration } from '@sentry/server-utils';

const INTEGRATION_NAME = 'Mongoose' as const;

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

const _mongooseIntegration = (() => {
return {
// The diagnostics_channel subscription (mongoose >= 9.7) lives in server-utils so it is shared
// across server runtimes; we extend it here to also run the IITM-based patcher for mongoose < 9.7.
return extendIntegration(mongooseChannelIntegration(), {
name: INTEGRATION_NAME,
setupOnce() {
instrumentMongoose();
},
};
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preload skips mongoose channel subscribe

Medium Severity

For mongoose >=9.7, diagnostics-channel subscription runs only in the server-utils integration setupOnce, while preloadOpenTelemetry invokes instrumentMongoose alone. The IITM patcher no longer covers 9.7+, so mongoose work between preload and Sentry.init() (or any preload-only usage) is not traced.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9cbf9df. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that is ok.

}) satisfies IntegrationFn;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ export class MongooseInstrumentation extends InstrumentationBase<Instrumentation
protected init(): InstrumentationModuleDefinition {
const module = new InstrumentationNodeModuleDefinition(
'mongoose',
['>=5.9.7 <10'],
// mongoose >= 9.7.0 publishes via diagnostics_channel and is instrumented by
// `subscribeMongooseDiagnosticChannels` instead, so this IITM patcher must not
// overlap it — otherwise every operation would emit two mongoose spans.
['>=5.9.7 <9.7.0'],
this.patch.bind(this),
this.unpatch.bind(this),
);
Expand Down
1 change: 1 addition & 0 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* @module
*/

export { mongooseIntegration } from './mongoose';
export {
IOREDIS_DC_CHANNEL_COMMAND,
IOREDIS_DC_CHANNEL_CONNECT,
Expand Down
30 changes: 30 additions & 0 deletions packages/server-utils/src/mongoose/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { defineIntegration, type IntegrationFn, waitForTracingChannelBinding } from '@sentry/core';
import * as dc from 'node:diagnostics_channel';
import { subscribeMongooseDiagnosticChannels } from './mongoose-dc-subscriber';

const _mongooseIntegration = (() => {
return {
name: 'Mongoose',
setupOnce() {
// Bail on Node <= 18.18.0, where `tracingChannel` does not exist.
if (!dc.tracingChannel) {
return;
}

// Subscribe to mongoose's native tracing channels (mongoose >= 9.7).
// This is a no-op on versions that don't publish to the channels, so it is always safe to call.
waitForTracingChannelBinding(() => {
subscribeMongooseDiagnosticChannels(dc.tracingChannel);
});
},
};
}) satisfies IntegrationFn;

/**
* Auto-instrument the [mongoose](https://www.npmjs.com/package/mongoose) library via its native
* `node:diagnostics_channel` tracing channels (mongoose >= 9.7).
*
* On older mongoose versions the channels are never published to, so this integration is inert and
* the IITM-based patcher (gated to `< 9.7.0`) handles instrumentation instead.
*/
export const mongooseIntegration = defineIntegration(_mongooseIntegration);
Loading
Loading