diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 3f7dfb18900e..c59909da82ce 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -161,7 +161,9 @@ "**/integrations/tracing/fastify/vendored/**/*.ts" ], "rules": { - "typescript/no-explicit-any": "off" + "typescript/no-explicit-any": "off", + "no-unsafe-member-access": "off", + "no-this-alias": "off" } }, { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/instrument.mjs @@ -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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/scenario.mjs new file mode 100644 index 000000000000..0463ed0b3680 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/scenario.mjs @@ -0,0 +1,36 @@ +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(); + + await BlogPost.findOne({}); + + await BlogPost.aggregate([{ $match: {} }]); + + await BlogPost.insertMany([{ title: 'Insert', body: 'Insert body', date: new Date() }]); + + await BlogPost.bulkWrite([{ insertOne: { document: { title: 'Bulk', body: 'Bulk body', date: new Date() } } }]); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts new file mode 100644 index 000000000000..dde237210761 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts @@ -0,0 +1,55 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongoose 7 so the `contextCaptureFunctions7` version branch is exercised against a real mongoose. +describe('Mongoose v7 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) => + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.operation': operation, + 'db.system': 'mongoose', + }), + description: `mongoose.BlogPost.${operation}`, + op: 'db', + origin: 'auto.db.otel.mongoose', + }); + + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expectedSpan('save'), + expectedSpan('findOne'), + expectedSpan('aggregate'), + expectedSpan('insertMany'), + expectedSpan('bulkWrite'), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongoose` v7.', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }, + { additionalDependencies: { mongoose: '^7' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/instrument.mjs @@ -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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/scenario.mjs new file mode 100644 index 000000000000..15c77d6a3827 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/scenario.mjs @@ -0,0 +1,56 @@ +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(); + + await BlogPost.findOne({}); + + // Document instance methods. On mongoose 8.21.0+ these return a lazy Query that the + // instrumentation must hand back un-executed (regression guard for the thenable trap). + await post.updateOne({ title: 'Updated' }); + + // Verify the update actually persisted (i.e. the query executed exactly when awaited). + const updated = await BlogPost.findById(post._id); + if (!updated || updated.title !== 'Updated') { + throw new Error(`updateOne did not persist as expected, got: ${updated && updated.title}`); + } + + // Lazy-Query guard: a document updateOne returns a lazy Query that only runs when awaited. + // Building it without awaiting must NOT execute it — if the instrumentation runs it (e.g. by + // calling `.then()` on the returned thenable), this premature write would change the document. + const lazyDoc = await new BlogPost({ title: 'Original', body: 'b', date: new Date() }).save(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + lazyDoc.updateOne({ title: 'PrematurelyExecuted' }); + await new Promise(resolve => setTimeout(resolve, 250)); + const lazyCheck = await BlogPost.findById(lazyDoc._id); + if (!lazyCheck || lazyCheck.title !== 'Original') { + throw new Error( + `lazy updateOne was executed without being awaited (got title: ${lazyCheck && lazyCheck.title})`, + ); + } + + await post.deleteOne(); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts new file mode 100644 index 000000000000..48e3faefbf0c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts @@ -0,0 +1,69 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongoose 8 (>= 8.21) so the document `updateOne`/`deleteOne` lazy-Query path is exercised +// against a real mongoose, guarding the thenable trap that mongoose 6 (the workspace version) can't hit. +describe('Mongoose v8 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 EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.operation': 'save', + 'db.system': 'mongoose', + }), + description: 'mongoose.BlogPost.save', + op: 'db', + origin: 'auto.db.otel.mongoose', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.operation': 'updateOne', + 'db.system': 'mongoose', + }), + description: 'mongoose.BlogPost.updateOne', + op: 'db', + origin: 'auto.db.otel.mongoose', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.operation': 'deleteOne', + 'db.system': 'mongoose', + }), + description: 'mongoose.BlogPost.deleteOne', + op: 'db', + origin: 'auto.db.otel.mongoose', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongoose` v8 document methods.', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }, + { additionalDependencies: { mongoose: '^8' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/instrument.mjs @@ -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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/scenario.mjs new file mode 100644 index 000000000000..cfdacfd08646 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/scenario.mjs @@ -0,0 +1,42 @@ +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(); + + await BlogPost.findOne({}); + + await BlogPost.aggregate([{ $match: {} }]); + + await BlogPost.insertMany([{ title: 'Insert', body: 'Insert body', date: new Date() }]); + + await BlogPost.bulkWrite([{ insertOne: { document: { title: 'Bulk', body: 'Bulk body', date: new Date() } } }]); + + // Document instance methods. On v9 these are not doc-method-patched (needsDocumentMethodPatch + // only matches 8.x) but are still instrumented via the patched Query.exec path. + const doc = await BlogPost.create({ title: 'DocMethod', body: 'b', date: new Date() }); + await doc.updateOne({ title: 'DocMethodUpdated' }); + await doc.deleteOne(); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts new file mode 100644 index 000000000000..3b2b48882254 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts @@ -0,0 +1,60 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +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. +conditionalTest({ min: 20 })('Mongoose v9 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) => + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.operation': operation, + 'db.system': 'mongoose', + }), + description: `mongoose.BlogPost.${operation}`, + op: 'db', + origin: 'auto.db.otel.mongoose', + }); + + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expectedSpan('save'), + expectedSpan('findOne'), + expectedSpan('aggregate'), + expectedSpan('insertMany'), + expectedSpan('bulkWrite'), + // Document instance methods are instrumented via Query.exec on v9 (no doc-method patch). + expectedSpan('updateOne'), + expectedSpan('deleteOne'), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongoose` v9.', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }, + { additionalDependencies: { mongoose: '^9' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs index 434c1db10e7d..94eb43482d4e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs @@ -28,6 +28,44 @@ async function run() { await post.save(); await BlogPost.findOne({}); + + // Callback form (mongoose 5/6 only): the callback is passed as the sole argument, so it must + // be forwarded in the correct position. Reject if the callback doesn't receive the saved doc. + await new Promise((resolve, reject) => { + new BlogPost({ title: 'Callback', body: 'cb', date: new Date() }).save((err, doc) => { + if (err) { + reject(err); + } else if (!doc || doc.title !== 'Callback') { + reject(new Error('save(callback) did not receive the saved document')); + } else { + resolve(); + } + }); + }); + + await BlogPost.aggregate([{ $match: {} }]); + + await BlogPost.insertMany([{ title: 'Insert', body: 'Insert body', date: new Date() }]); + + await BlogPost.bulkWrite([{ insertOne: { document: { title: 'Bulk', body: 'Bulk body', date: new Date() } } }]); + + // `remove` is a real document method (deprecated in 6, removed in 7), only patched for v5/6. + const toRemove = await BlogPost.create({ title: 'Remove', body: 'r', date: new Date() }); + await toRemove.remove(); + + // Cross-context parent: a query built inside one span but executed after it ends should still + // be parented to the span it was built in (via _STORED_PARENT_SPAN), not the active span at exec. + let pendingQuery; + Sentry.startSpan({ name: 'query-builder' }, () => { + pendingQuery = BlogPost.findOne({ title: 'Test' }); + }); + await pendingQuery; + + // Failing operation: a save that violates required-field validation should still produce a + // span, marked with an error status. + const RequiredSchema = new Schema({ requiredField: { type: String, required: true } }); + const RequiredDoc = mongoose.model('RequiredDoc', RequiredSchema); + await new RequiredDoc({}).save().catch(() => undefined); }, ); } diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts index 14b5bc777098..ed20aff13496 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -42,6 +42,62 @@ describe('Mongoose experimental Test', () => { op: 'db', origin: 'auto.db.otel.mongoose', }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.name': 'test', + 'db.operation': 'aggregate', + 'db.system': 'mongoose', + }), + description: 'mongoose.BlogPost.aggregate', + op: 'db', + origin: 'auto.db.otel.mongoose', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.name': 'test', + 'db.operation': 'insertMany', + 'db.system': 'mongoose', + }), + description: 'mongoose.BlogPost.insertMany', + op: 'db', + origin: 'auto.db.otel.mongoose', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.name': 'test', + 'db.operation': 'bulkWrite', + 'db.system': 'mongoose', + }), + description: 'mongoose.BlogPost.bulkWrite', + op: 'db', + origin: 'auto.db.otel.mongoose', + }), + // `remove` is patched only on mongoose 5/6. + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.name': 'test', + 'db.operation': 'remove', + 'db.system': 'mongoose', + }), + description: 'mongoose.BlogPost.remove', + op: 'db', + origin: 'auto.db.otel.mongoose', + }), + // A failing operation still produces a span, marked with an error status. + expect.objectContaining({ + data: expect.objectContaining({ + 'db.operation': 'save', + 'db.system': 'mongoose', + }), + description: 'mongoose.RequiredDoc.save', + op: 'db', + origin: 'auto.db.otel.mongoose', + status: 'internal_error', + }), ]), }; @@ -49,5 +105,42 @@ describe('Mongoose experimental Test', () => { test('should auto-instrument `mongoose` package.', async () => { await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); + + test('nests the mongodb driver span under the mongoose span', async () => { + await createTestRunner() + .expect({ + transaction: event => { + const spans = event.spans || []; + const mongooseSave = spans.find(span => span.description === 'mongoose.BlogPost.save'); + expect(mongooseSave).toBeDefined(); + // the underlying mongodb driver span must be parented to the mongoose span + const driverChild = spans.find( + span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo', + ); + expect(driverChild).toBeDefined(); + }, + }) + .start() + .completed(); + }); + + test('parents a query to the span it was built in, not where it executes', async () => { + await createTestRunner() + .expect({ + transaction: event => { + const spans = event.spans || []; + const builder = spans.find(span => span.description === 'query-builder'); + expect(builder).toBeDefined(); + // the query was built inside `query-builder` but awaited after it ended, so its exec + // span must parent to `query-builder` rather than the active span at exec time + const findExec = spans.find( + span => span.description === 'mongoose.BlogPost.findOne' && span.parent_span_id === builder?.span_id, + ); + expect(findExec).toBeDefined(); + }, + }) + .start() + .completed(); + }); }); }); diff --git a/packages/node/src/integrations/tracing/mongoose/index.ts b/packages/node/src/integrations/tracing/mongoose/index.ts index 811eb0bd7905..67d2978a423c 100644 --- a/packages/node/src/integrations/tracing/mongoose/index.ts +++ b/packages/node/src/integrations/tracing/mongoose/index.ts @@ -1,19 +1,11 @@ import { MongooseInstrumentation } from './vendored/mongoose'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; +import { generateInstrumentOnce } from '@sentry/node-core'; const INTEGRATION_NAME = 'Mongoose'; -export const instrumentMongoose = generateInstrumentOnce( - INTEGRATION_NAME, - () => - new MongooseInstrumentation({ - responseHook(span) { - addOriginToSpan(span, 'auto.db.otel.mongoose'); - }, - }), -); +export const instrumentMongoose = generateInstrumentOnce(INTEGRATION_NAME, () => new MongooseInstrumentation()); const _mongooseIntegration = (() => { return { diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose-types.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose-types.ts index 5192316d5d0c..0d1753007b70 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose-types.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose-types.ts @@ -2,7 +2,6 @@ * Simplified type definitions vendored from mongoose. * Only includes the types actually accessed by the instrumentation. */ -/* eslint-disable */ export interface Collection { name: string; @@ -37,3 +36,7 @@ export declare const Aggregate: { prototype: any; [key: string]: any; }; + +export interface MongooseError extends Error { + code?: number; +} diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index 8ebf9bc15bdf..27a2d467bf04 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -18,26 +18,39 @@ * - Upstream version: @opentelemetry/instrumentation-mongoose@0.64.0 * - Types vendored from mongoose as simplified interfaces * - Minor TypeScript strictness adjustments for this repository's compiler settings + * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs */ -/* eslint-disable */ -import { context, Span, trace, Attributes, SpanKind } from '@opentelemetry/api'; -import { suppressTracing } from '@opentelemetry/core'; -import type * as mongoose from './mongoose-types'; -import { MongooseInstrumentationConfig, SerializerPayload } from './types'; -import { handleCallbackResponse, handlePromiseResponse, getAttributesFromCollection } from './utils'; +import { SpanKind } from '@opentelemetry/api'; import { InstrumentationBase, - InstrumentationModuleDefinition, + type InstrumentationModuleDefinition, InstrumentationNodeModuleDefinition, - SemconvStability, - semconvStabilityFromStr, } from '@opentelemetry/instrumentation'; -import { SDK_VERSION } from '@sentry/core'; -import { ATTR_DB_OPERATION, ATTR_DB_STATEMENT, ATTR_DB_SYSTEM, DB_SYSTEM_NAME_VALUE_MONGODB } from './semconv'; -import { ATTR_DB_OPERATION_NAME, ATTR_DB_QUERY_TEXT, ATTR_DB_SYSTEM_NAME } from '@opentelemetry/semantic-conventions'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { + getActiveSpan, + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import type * as mongoose from './mongoose-types'; +import { ATTR_DB_OPERATION, ATTR_DB_SYSTEM } from './semconv'; +import type { MongooseInstrumentationConfig } from './types'; +import { getAttributesFromCollection, handleCallbackResponse, handlePromiseResponse } from './utils'; const PACKAGE_NAME = '@sentry/instrumentation-mongoose'; +const ORIGIN = 'auto.db.otel.mongoose'; + +type MongooseModuleExports = typeof mongoose; + +// The raw imported `mongoose` module: either the CJS object itself, or an ESM +// namespace wrapper exposing the same shape under `.default`. +type MongooseModule = MongooseModuleExports & { + default?: MongooseModuleExports; + [Symbol.toStringTag]?: string; +}; const contextCaptureFunctionsCommon = [ 'deleteOne', @@ -98,18 +111,8 @@ export const _STORED_PARENT_SPAN: unique symbol = Symbol('stored-parent-span'); export const _ALREADY_INSTRUMENTED: unique symbol = Symbol('already-instrumented'); export class MongooseInstrumentation extends InstrumentationBase { - private _netSemconvStability!: SemconvStability; - private _dbSemconvStability!: SemconvStability; - constructor(config: MongooseInstrumentationConfig = {}) { super(PACKAGE_NAME, SDK_VERSION, config); - this._setSemconvStabilityFromEnv(); - } - - // Used for testing. - private _setSemconvStabilityFromEnv() { - this._netSemconvStability = semconvStabilityFromStr('http', process.env.OTEL_SEMCONV_STABILITY_OPT_IN); - this._dbSemconvStability = semconvStabilityFromStr('database', process.env.OTEL_SEMCONV_STABILITY_OPT_IN); } protected init(): InstrumentationModuleDefinition { @@ -122,10 +125,11 @@ export class MongooseInstrumentation extends InstrumentationBase { return function exec(this: any, callback?: Function) { - if (self.getConfig().requireParentSpan && trace.getSpan(context.active()) === undefined) { - return originalAggregate.apply(this, arguments); - } - const parentSpan = this[_STORED_PARENT_SPAN]; - const attributes: Attributes = {}; - const { dbStatementSerializer } = self.getConfig(); - if (dbStatementSerializer) { - const statement = dbStatementSerializer('aggregate', { - options: this.options, - aggregatePipeline: this._pipeline, - }); - if (self._dbSemconvStability & SemconvStability.OLD) { - attributes[ATTR_DB_STATEMENT] = statement; - } - if (self._dbSemconvStability & SemconvStability.STABLE) { - attributes[ATTR_DB_QUERY_TEXT] = statement; - } - } + const span = self._startSpan(this._model.collection, this._model?.modelName, 'aggregate', parentSpan); - const span = self._startSpan( - this._model.collection, - this._model?.modelName, - 'aggregate', - attributes, - parentSpan, - ); - - return self._handleResponse(span, originalAggregate, this, arguments, callback, moduleVersion); + return self._handleResponse(span, originalAggregate, this, arguments, callback); }; }; } - private patchQueryExec(moduleVersion: string | undefined) { + private patchQueryExec() { const self = this; return (originalExec: Function) => { return function exec(this: any, callback?: Function) { @@ -242,113 +214,46 @@ export class MongooseInstrumentation extends InstrumentationBase { return function method(this: any, options?: any, callback?: Function) { - if (self.getConfig().requireParentSpan && trace.getSpan(context.active()) === undefined) { - return originalOnModelFunction.apply(this, arguments); - } - - const serializePayload: SerializerPayload = { document: this }; - if (options && !(options instanceof Function)) { - serializePayload.options = options; - } - const attributes: Attributes = {}; - const { dbStatementSerializer } = self.getConfig(); - if (dbStatementSerializer) { - const statement = dbStatementSerializer(op, serializePayload); - if (self._dbSemconvStability & SemconvStability.OLD) { - attributes[ATTR_DB_STATEMENT] = statement; - } - if (self._dbSemconvStability & SemconvStability.STABLE) { - attributes[ATTR_DB_QUERY_TEXT] = statement; - } - } - const span = self._startSpan(this.constructor.collection, this.constructor.modelName, op, attributes); + const span = self._startSpan(this.constructor.collection, this.constructor.modelName, op); if (options instanceof Function) { + // oxlint-disable-next-line no-param-reassign callback = options; - options = undefined; } - return self._handleResponse(span, originalOnModelFunction, this, arguments, callback, moduleVersion); + return self._handleResponse(span, originalOnModelFunction, this, arguments, callback); }; }; } // Patch document instance methods (doc.updateOne/deleteOne) for Mongoose 8.21.0+. - private _patchDocumentUpdateMethods(op: string, moduleVersion: string | undefined) { + private _patchDocumentUpdateMethods(op: string) { const self = this; return (originalMethod: Function) => { return function method(this: any, update?: any, options?: any, callback?: Function) { - if (self.getConfig().requireParentSpan && trace.getSpan(context.active()) === undefined) { - return originalMethod.apply(this, arguments); - } - // determine actual callback since different argument patterns are allowed let actualCallback: Function | undefined = callback; - let actualUpdate = update; - let actualOptions = options; - if (typeof update === 'function') { actualCallback = update; - actualUpdate = undefined; - actualOptions = undefined; } else if (typeof options === 'function') { actualCallback = options; - actualOptions = undefined; - } - - const attributes: Attributes = {}; - const dbStatementSerializer = self.getConfig().dbStatementSerializer; - if (dbStatementSerializer) { - const statement = dbStatementSerializer(op, { - // Document instance methods automatically use the document's _id as filter - condition: { _id: this._id }, - updates: actualUpdate, - options: actualOptions, - }); - if (self._dbSemconvStability & SemconvStability.OLD) { - attributes[ATTR_DB_STATEMENT] = statement; - } - if (self._dbSemconvStability & SemconvStability.STABLE) { - attributes[ATTR_DB_QUERY_TEXT] = statement; - } } - const span = self._startSpan(this.constructor.collection, this.constructor.modelName, op, attributes); + const span = self._startSpan(this.constructor.collection, this.constructor.modelName, op); - const result = self._handleResponse(span, originalMethod, this, arguments, actualCallback, moduleVersion); + const result = self._handleResponse(span, originalMethod, this, arguments, actualCallback); // Mark returned Query to prevent double-instrumentation when exec() is eventually called if (result && typeof result === 'object') { @@ -360,49 +265,18 @@ export class MongooseInstrumentation extends InstrumentationBase { return function patchedStatic(this: any, docsOrOps: any, options?: any, callback?: Function) { - if (self.getConfig().requireParentSpan && trace.getSpan(context.active()) === undefined) { - return original.apply(this, arguments); - } if (typeof options === 'function') { + // oxlint-disable-next-line no-param-reassign callback = options; - options = undefined; } - const serializePayload: SerializerPayload = {}; - switch (op) { - case 'insertMany': - serializePayload.documents = docsOrOps; - break; - case 'bulkWrite': - serializePayload.operations = docsOrOps; - break; - default: - serializePayload.document = docsOrOps; - break; - } - if (options !== undefined) { - serializePayload.options = options; - } + const span = self._startSpan(this.collection, this.modelName, op); - const attributes: Attributes = {}; - const { dbStatementSerializer } = self.getConfig(); - if (dbStatementSerializer) { - const statement = dbStatementSerializer(op, serializePayload); - if (self._dbSemconvStability & SemconvStability.OLD) { - attributes[ATTR_DB_STATEMENT] = statement; - } - if (self._dbSemconvStability & SemconvStability.STABLE) { - attributes[ATTR_DB_QUERY_TEXT] = statement; - } - } - - const span = self._startSpan(this.collection, this.modelName, op, attributes); - - return self._handleResponse(span, original, this, arguments, callback, moduleVersion); + return self._handleResponse(span, original, this, arguments, callback); }; }; } @@ -412,87 +286,52 @@ export class MongooseInstrumentation extends InstrumentationBase { return function captureSpanContext(this: any) { - const currentSpan = trace.getSpan(context.active()); - const aggregate = self._callOriginalFunction(() => original.apply(this, arguments)); + const currentSpan = getActiveSpan(); + const aggregate = original.apply(this, arguments); if (aggregate) aggregate[_STORED_PARENT_SPAN] = currentSpan; return aggregate; }; }; } - private patchAndCaptureSpanContext(funcName: string) { - const self = this; + private patchAndCaptureSpanContext(_funcName: string) { return (original: Function) => { return function captureSpanContext(this: any) { - this[_STORED_PARENT_SPAN] = trace.getSpan(context.active()); - return self._callOriginalFunction(() => original.apply(this, arguments)); + this[_STORED_PARENT_SPAN] = getActiveSpan(); + return original.apply(this, arguments); }; }; } - private _startSpan( - collection: mongoose.Collection, - modelName: string, - operation: string, - attributes: Attributes, - parentSpan?: Span, - ): Span { - const finalAttributes: Attributes = { - ...attributes, - ...getAttributesFromCollection(collection, this._dbSemconvStability, this._netSemconvStability), + private _startSpan(collection: mongoose.Collection, modelName: string, operation: string, parentSpan?: Span): Span { + const attributes: SpanAttributes = { + ...getAttributesFromCollection(collection), + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_SYSTEM]: 'mongoose', // keep for backwards compatibility + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, }; - if (this._dbSemconvStability & SemconvStability.OLD) { - finalAttributes[ATTR_DB_OPERATION] = operation; - finalAttributes[ATTR_DB_SYSTEM] = 'mongoose'; // keep for backwards compatibility - } - if (this._dbSemconvStability & SemconvStability.STABLE) { - finalAttributes[ATTR_DB_OPERATION_NAME] = operation; - finalAttributes[ATTR_DB_SYSTEM_NAME] = DB_SYSTEM_NAME_VALUE_MONGODB; // actual db system name - } - - const spanName = - this._dbSemconvStability & SemconvStability.STABLE - ? `${operation} ${collection.name}` - : `mongoose.${modelName}.${operation}`; - - return this.tracer.startSpan( - spanName, - { - kind: SpanKind.CLIENT, - attributes: finalAttributes, - }, - parentSpan ? trace.setSpan(context.active(), parentSpan) : undefined, - ); - } - - private _handleResponse( - span: Span, - exec: Function, - originalThis: any, - args: IArguments, - callback?: Function, - moduleVersion: string | undefined = undefined, - ) { - const self = this; - if (callback instanceof Function) { - return self._callOriginalFunction(() => - handleCallbackResponse(callback, exec, originalThis, span, args, self.getConfig().responseHook, moduleVersion), - ); - } else { - const response = self._callOriginalFunction(() => exec.apply(originalThis, args)); - return handlePromiseResponse(response, span, self.getConfig().responseHook, moduleVersion); - } + return startInactiveSpan({ + name: `mongoose.${modelName}.${operation}`, + kind: SpanKind.CLIENT, + attributes, + parentSpan, + }); } - private _callOriginalFunction(originalFunction: (...args: any[]) => T): T { - if (this.getConfig().suppressInternalInstrumentation) { - return context.with(suppressTracing(context.active()), originalFunction); - } else { - return originalFunction(); - } + private _handleResponse(span: Span, exec: Function, originalThis: any, args: IArguments, callback?: Function) { + // Activate the span while the underlying operation runs so that nested instrumentation + // (e.g. the mongodb driver spans) is parented to this span. `withActiveSpan` returns the + // callback's result untouched, so lazy mongoose Query thenables are handed back unexecuted. + return withActiveSpan(span, () => { + if (callback instanceof Function) { + return handleCallbackResponse(callback, exec, originalThis, span, args); + } else { + const response = exec.apply(originalThis, args); + return handlePromiseResponse(response, span); + } + }); } } diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts b/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts index db82a1d7d237..99a8d8b7293b 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts @@ -17,7 +17,6 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongoose * - Upstream version: @opentelemetry/instrumentation-mongoose@0.64.0 */ -/* eslint-disable */ /** * Deprecated, use `db.collection.name` instead. @@ -46,15 +45,6 @@ export const ATTR_DB_NAME = 'db.name' as const; */ export const ATTR_DB_OPERATION = 'db.operation' as const; -/** - * The database statement being executed. - * - * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. - * - * @deprecated Replaced by `db.query.text`. - */ -export const ATTR_DB_STATEMENT = 'db.statement' as const; - /** * Deprecated, use `db.system.name` instead. * @@ -90,10 +80,3 @@ export const ATTR_NET_PEER_NAME = 'net.peer.name' as const; * @deprecated Replaced by `server.port` on client spans and `client.port` on server spans. */ export const ATTR_NET_PEER_PORT = 'net.peer.port' as const; - -/** - * Enum value "mongodb" for attribute {@link ATTR_DB_SYSTEM_NAME}. - * - * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. - */ -export const DB_SYSTEM_NAME_VALUE_MONGODB = 'mongodb' as const; diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts b/packages/node/src/integrations/tracing/mongoose/vendored/types.ts index 761cb26bd8f8..13443e7daba2 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/types.ts @@ -17,48 +17,7 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongoose * - Upstream version: @opentelemetry/instrumentation-mongoose@0.64.0 */ -/* eslint-disable */ -import { Span } from '@opentelemetry/api'; -import { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; -export interface SerializerPayload { - condition?: any; - options?: any; - updates?: any; - document?: any; - aggregatePipeline?: any; - fields?: any; - documents?: any; - operations?: any; -} - -export type DbStatementSerializer = (operation: string, payload: SerializerPayload) => string; - -export interface ResponseInfo { - moduleVersion: string | undefined; - response: any; -} - -export type MongooseResponseCustomAttributesFunction = (span: Span, responseInfo: ResponseInfo) => void; - -export interface MongooseInstrumentationConfig extends InstrumentationConfig { - /** - * Mongoose operation use mongodb under the hood. - * If mongodb instrumentation is enabled, a mongoose operation will also create - * a mongodb operation describing the communication with mongoDB servers. - * Setting the `suppressInternalInstrumentation` config value to `true` will - * cause the instrumentation to suppress instrumentation of underlying operations, - * effectively causing mongodb spans to be non-recordable. - */ - suppressInternalInstrumentation?: boolean; - - /** Custom serializer function for the db.statement tag */ - dbStatementSerializer?: DbStatementSerializer; - - /** hook for adding custom attributes using the response payload */ - responseHook?: MongooseResponseCustomAttributesFunction; - - /** Set to true if you do not want to collect traces that start with mongoose */ - requireParentSpan?: boolean; -} +export type MongooseInstrumentationConfig = InstrumentationConfig; diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts b/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts index 8e4824380404..e49fa571d198 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts @@ -17,13 +17,12 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongoose * - Upstream version: @opentelemetry/instrumentation-mongoose@0.64.0 * - Types vendored from mongoose as simplified interfaces + * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs */ -/* eslint-disable */ -import { Attributes, SpanStatusCode, diag, Span } from '@opentelemetry/api'; -import type { Collection } from './mongoose-types'; -import { MongooseResponseCustomAttributesFunction } from './types'; -import { safeExecuteInTheMiddle, SemconvStability } from '@opentelemetry/instrumentation'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { SPAN_STATUS_ERROR } from '@sentry/core'; +import type { Collection, MongooseError } from './mongoose-types'; import { ATTR_DB_MONGODB_COLLECTION, ATTR_DB_NAME, @@ -31,90 +30,32 @@ import { ATTR_NET_PEER_NAME, ATTR_NET_PEER_PORT, } from './semconv'; -import { - ATTR_DB_COLLECTION_NAME, - ATTR_DB_NAMESPACE, - ATTR_SERVER_ADDRESS, - ATTR_SERVER_PORT, -} from '@opentelemetry/semantic-conventions'; - -export function getAttributesFromCollection( - collection: Collection, - dbSemconvStability: SemconvStability, - netSemconvStability: SemconvStability, -): Attributes { - const attrs: Attributes = {}; - - if (dbSemconvStability & SemconvStability.OLD) { - attrs[ATTR_DB_MONGODB_COLLECTION] = collection.name; - attrs[ATTR_DB_NAME] = collection.conn.name; - attrs[ATTR_DB_USER] = collection.conn.user; - } - if (dbSemconvStability & SemconvStability.STABLE) { - attrs[ATTR_DB_COLLECTION_NAME] = collection.name; - attrs[ATTR_DB_NAMESPACE] = collection.conn.name; - } - if (netSemconvStability & SemconvStability.OLD) { - attrs[ATTR_NET_PEER_NAME] = collection.conn.host; - attrs[ATTR_NET_PEER_PORT] = collection.conn.port; - } - if (netSemconvStability & SemconvStability.STABLE) { - attrs[ATTR_SERVER_ADDRESS] = collection.conn.host; - attrs[ATTR_SERVER_PORT] = collection.conn.port; - } - - return attrs; +export function getAttributesFromCollection(collection: Collection): SpanAttributes { + return { + [ATTR_DB_MONGODB_COLLECTION]: collection.name, + [ATTR_DB_NAME]: collection.conn.name, + [ATTR_DB_USER]: collection.conn.user, + [ATTR_NET_PEER_NAME]: collection.conn.host, + [ATTR_NET_PEER_PORT]: collection.conn.port, + }; } -function setErrorStatus(span: Span, error: any = {}) { - span.recordException(error); - +function setErrorStatus(span: Span, error: MongooseError): void { span.setStatus({ - code: SpanStatusCode.ERROR, + code: SPAN_STATUS_ERROR, message: `${error.message} ${error.code ? `\nMongoose Error Code: ${error.code}` : ''}`, }); } -function applyResponseHook( - span: Span, - response: any, - responseHook?: MongooseResponseCustomAttributesFunction, - moduleVersion: string | undefined = undefined, -) { - if (!responseHook) { - return; - } - - safeExecuteInTheMiddle( - () => responseHook(span, { moduleVersion, response }), - e => { - if (e) { - diag.error('mongoose instrumentation: responseHook error', e); - } - }, - true, - ); -} - -export function handlePromiseResponse( - execResponse: any, - span: Span, - responseHook?: MongooseResponseCustomAttributesFunction, - moduleVersion: string | undefined = undefined, -): any { +export function handlePromiseResponse(execResponse: any, span: Span): any { if (!(execResponse instanceof Promise)) { - applyResponseHook(span, execResponse, responseHook, moduleVersion); span.end(); return execResponse; } return execResponse - .then(response => { - applyResponseHook(span, response, responseHook, moduleVersion); - return response; - }) - .catch(err => { + .catch((err: any) => { setErrorStatus(span, err); throw err; }) @@ -127,8 +68,6 @@ export function handleCallbackResponse( originalThis: any, span: Span, args: IArguments, - responseHook?: MongooseResponseCustomAttributesFunction, - moduleVersion: string | undefined = undefined, ) { let callbackArgumentIndex = 0; if (args.length === 2) { @@ -140,12 +79,10 @@ export function handleCallbackResponse( args[callbackArgumentIndex] = (err: Error, response: any): any => { if (err) { setErrorStatus(span, err); - } else { - applyResponseHook(span, response, responseHook, moduleVersion); } span.end(); - return callback!(err, response); + return callback(err, response); }; return exec.apply(originalThis, args);