From 7c07b758094d31a8e6b646c146429bafa9778881 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 15:25:29 -0400 Subject: [PATCH 01/16] ref(node): Swap mongoose vendored span lifecycle to Sentry APIs Replace the OpenTelemetry tracing APIs in the vendored mongoose instrumentation with Sentry primitives (startInactiveSpan, getActiveSpan, span.setStatus) and bake the span origin in directly. Also drops the config paths that are dead in Sentry's context (dbStatementSerializer, requireParentSpan, suppressInternalInstrumentation) and the SemconvStability dual-emission machinery, keeping only the OLD attribute set. The OpenTelemetry instrumentation base/module-patching layer is kept. --- .../integrations/tracing/mongoose/index.ts | 12 +- .../tracing/mongoose/vendored/mongoose.ts | 288 ++++-------------- .../tracing/mongoose/vendored/utils.ts | 92 +----- 3 files changed, 69 insertions(+), 323 deletions(-) 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.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index 8ebf9bc15bdf..cfa831b6f7a7 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -18,26 +18,25 @@ * - 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, 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 } from '@sentry/core'; +import type * as mongoose from './mongoose-types'; +import { ATTR_DB_OPERATION, ATTR_DB_SYSTEM } from './semconv'; +import { MongooseInstrumentationConfig } from './types'; +import { getAttributesFromCollection, handleCallbackResponse, handlePromiseResponse } from './utils'; const PACKAGE_NAME = '@sentry/instrumentation-mongoose'; +const ORIGIN = 'auto.db.otel.mongoose'; const contextCaptureFunctionsCommon = [ 'deleteOne', @@ -98,18 +97,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 { @@ -125,7 +114,7 @@ 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 +198,45 @@ 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) { 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 +248,17 @@ 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') { 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 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); + const span = self._startSpan(this.collection, this.modelName, op); - return self._handleResponse(span, original, this, arguments, callback, moduleVersion); + return self._handleResponse(span, original, this, arguments, callback); }; }; } @@ -412,11 +268,10 @@ 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; }; @@ -424,75 +279,36 @@ export class MongooseInstrumentation extends InstrumentationBase { 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, - ); + return startInactiveSpan({ + name: `mongoose.${modelName}.${operation}`, + kind: SpanKind.CLIENT, + attributes, + parentSpan, + }); } - private _handleResponse( - span: Span, - exec: Function, - originalThis: any, - args: IArguments, - callback?: Function, - moduleVersion: string | undefined = undefined, - ) { - const self = this; + private _handleResponse(span: Span, exec: Function, originalThis: any, args: IArguments, callback?: Function) { 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); - } - } - - private _callOriginalFunction(originalFunction: (...args: any[]) => T): T { - if (this.getConfig().suppressInternalInstrumentation) { - return context.with(suppressTracing(context.active()), originalFunction); + return handleCallbackResponse(callback, exec, originalThis, span, args); } else { - return originalFunction(); + const response = exec.apply(originalThis, args); + return handlePromiseResponse(response, span); } } } diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts b/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts index 8e4824380404..28ffe5b44352 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts @@ -17,13 +17,13 @@ * - 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 { Span, SpanAttributes } from '@sentry/core'; +import { SPAN_STATUS_ERROR } from '@sentry/core'; import type { Collection } from './mongoose-types'; -import { MongooseResponseCustomAttributesFunction } from './types'; -import { safeExecuteInTheMiddle, SemconvStability } from '@opentelemetry/instrumentation'; import { ATTR_DB_MONGODB_COLLECTION, ATTR_DB_NAME, @@ -31,90 +31,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: any = {}): 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 +69,6 @@ export function handleCallbackResponse( originalThis: any, span: Span, args: IArguments, - responseHook?: MongooseResponseCustomAttributesFunction, - moduleVersion: string | undefined = undefined, ) { let callbackArgumentIndex = 0; if (args.length === 2) { @@ -140,8 +80,6 @@ export function handleCallbackResponse( args[callbackArgumentIndex] = (err: Error, response: any): any => { if (err) { setErrorStatus(span, err); - } else { - applyResponseHook(span, response, responseHook, moduleVersion); } span.end(); From ad5b5d260dd3042034b8227d593eb8e0b49c6c33 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 15:30:31 -0400 Subject: [PATCH 02/16] ref(node): Prune dead mongoose instrumentation config types With the OTel responseHook/dbStatementSerializer paths removed, the only config the SDK uses is the base InstrumentationConfig. Collapse MongooseInstrumentationConfig to that and drop the now-unused SerializerPayload, DbStatementSerializer, ResponseInfo and MongooseResponseCustomAttributesFunction types. --- .../tracing/mongoose/vendored/types.ts | 44 +------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts b/packages/node/src/integrations/tracing/mongoose/vendored/types.ts index 761cb26bd8f8..d1e438e30c1f 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/types.ts @@ -19,46 +19,6 @@ */ /* 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; From 570773482a6e1dc6aac9c35625a9c0e3297f2036 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 15:31:49 -0400 Subject: [PATCH 03/16] ref(node): Drop unused mongoose semconv constants Remove ATTR_DB_STATEMENT (fed the removed dbStatementSerializer path) and DB_SYSTEM_NAME_VALUE_MONGODB (only used by the dropped stable-semconv branch). The remaining constants are the OLD attribute set the instrumentation still emits. --- .../tracing/mongoose/vendored/semconv.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts b/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts index db82a1d7d237..3767c71aa875 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts @@ -46,15 +46,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 +81,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; From b36eb0cfb9246193802e88f04cccc0b1b65f600d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 15:41:50 -0400 Subject: [PATCH 04/16] chore: remove eslint ignores and type the error object --- .../tracing/mongoose/vendored/mongoose-types.ts | 5 ++++- .../src/integrations/tracing/mongoose/vendored/semconv.ts | 1 - .../src/integrations/tracing/mongoose/vendored/types.ts | 1 - .../src/integrations/tracing/mongoose/vendored/utils.ts | 7 +++---- 4 files changed, 7 insertions(+), 7 deletions(-) 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/semconv.ts b/packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts index 3767c71aa875..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. diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts b/packages/node/src/integrations/tracing/mongoose/vendored/types.ts index d1e438e30c1f..13443e7daba2 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/types.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 */ import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts b/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts index 28ffe5b44352..e49fa571d198 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/utils.ts @@ -19,11 +19,10 @@ * - Types vendored from mongoose as simplified interfaces * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs */ -/* eslint-disable */ import type { Span, SpanAttributes } from '@sentry/core'; import { SPAN_STATUS_ERROR } from '@sentry/core'; -import type { Collection } from './mongoose-types'; +import type { Collection, MongooseError } from './mongoose-types'; import { ATTR_DB_MONGODB_COLLECTION, ATTR_DB_NAME, @@ -42,7 +41,7 @@ export function getAttributesFromCollection(collection: Collection): SpanAttribu }; } -function setErrorStatus(span: Span, error: any = {}): void { +function setErrorStatus(span: Span, error: MongooseError): void { span.setStatus({ code: SPAN_STATUS_ERROR, message: `${error.message} ${error.code ? `\nMongoose Error Code: ${error.code}` : ''}`, @@ -83,7 +82,7 @@ export function handleCallbackResponse( } span.end(); - return callback!(err, response); + return callback(err, response); }; return exec.apply(originalThis, args); From fb8a47bb56e0e22765e84e6ccceed7019ab113d1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 15:43:25 -0400 Subject: [PATCH 05/16] test(node): Add mongoose instrumentation unit tests Port the upstream OTel mongoose test coverage as unit tests that drive a fake mongoose module through MongooseInstrumentation and assert the produced Sentry spans. Covers save, query exec, aggregate, insertMany, bulkWrite, document update methods (v8.21+), remove (v5/v6), error status, parent-span linking and unpatch. --- .../integrations/tracing/mongoose.test.ts | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 packages/node/test/integrations/tracing/mongoose.test.ts diff --git a/packages/node/test/integrations/tracing/mongoose.test.ts b/packages/node/test/integrations/tracing/mongoose.test.ts new file mode 100644 index 000000000000..2bc419e310af --- /dev/null +++ b/packages/node/test/integrations/tracing/mongoose.test.ts @@ -0,0 +1,329 @@ +/* + * Tests ported from @opentelemetry/instrumentation-mongoose@0.64.0 + * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-mongoose + * Licensed under the Apache License, Version 2.0 + * + * The upstream suite runs against a real mongoose + mongodb. Here we exercise the + * same operation coverage against a fake mongoose module so the instrumentation + * logic (span name, attributes, origin, error status, parent linking, patch/unpatch) + * can be unit tested without a database. + */ + +import type { SpanJSON } from '@sentry/core'; +import { getClient, spanToJSON } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as Sentry from '../../../src'; +import { + _ALREADY_INSTRUMENTED, + MongooseInstrumentation, +} from '../../../src/integrations/tracing/mongoose/vendored/mongoose'; +import { cleanupOtel, mockSdkInit } from '../../helpers/mockSdkInit'; + +type AnyFn = (...args: any[]) => any; + +const ORIGIN = 'auto.db.otel.mongoose'; + +const CONN = { name: 'test', host: 'localhost', port: 27017, user: 'admin' }; +const COLLECTION = { name: 'users', conn: CONN }; + +// Names the instrumentation wraps on `Query.prototype` to capture the parent span. +const CONTEXT_CAPTURE_FUNCTIONS = [ + 'deleteOne', + 'deleteMany', + 'find', + 'findOne', + 'estimatedDocumentCount', + 'countDocuments', + 'distinct', + 'where', + '$where', + 'findOneAndUpdate', + 'findOneAndDelete', + 'findOneAndReplace', +]; + +// Returns an implementation that resolves/rejects as a promise, or invokes a trailing callback. +function fakeOp({ result = 'result', reject = false }: { result?: unknown; reject?: boolean } = {}): AnyFn { + return function (this: unknown, ...args: unknown[]): unknown { + const callback = args.find(arg => typeof arg === 'function') as AnyFn | undefined; + const error = reject ? Object.assign(new Error('boom'), { code: 123 }) : null; + + if (callback) { + callback(error, reject ? undefined : result); + return undefined; + } + + return reject ? Promise.reject(error) : Promise.resolve(result); + }; +} + +interface FakeMongoose { + Model: any; + Query: any; + Aggregate: any; +} + +function createFakeMongoose({ reject = false }: { reject?: boolean } = {}): FakeMongoose { + // NOTE: methods the instrumentation patches must live on the prototype (or the + // constructor for statics), since `_wrap` operates on prototype/static members. + class Query { + public op: string; + public mongooseCollection = COLLECTION; + public model = { modelName: 'User' }; + + public constructor(op: string) { + this.op = op; + } + } + + (Query.prototype as any).exec = fakeOp({ reject }); + // chainable context-capture functions all return `this` + CONTEXT_CAPTURE_FUNCTIONS.forEach(name => { + (Query.prototype as any)[name] = function (this: unknown) { + return this; + }; + }); + + class Aggregate { + public _model = { collection: COLLECTION, modelName: 'User' }; + public _pipeline: unknown[] = []; + public options = {}; + } + + (Aggregate.prototype as any).exec = fakeOp({ reject }); + + class Model { + public static collection = COLLECTION; + public static modelName = 'User'; + + public static aggregate(): Aggregate { + return new Aggregate(); + } + + public static insertMany = fakeOp({ reject, result: [] }); + public static bulkWrite = fakeOp({ reject, result: {} }); + + // Document instance methods (Mongoose 8.21.0+) return a Query. + public updateOne(): Query { + return new Query('updateOne'); + } + public deleteOne(): Query { + return new Query('deleteOne'); + } + } + + (Model.prototype as any).save = fakeOp({ reject }); + // Removed in Mongoose 7+, only patched for v5/v6. + (Model.prototype as any).remove = fakeOp({ reject }); + + return { Model, Query, Aggregate }; +} + +describe('mongoose instrumentation', () => { + let instrumentation: MongooseInstrumentation; + let finishedSpans: SpanJSON[]; + + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + instrumentation = new MongooseInstrumentation(); + + finishedSpans = []; + getClient()?.on('spanEnd', span => { + finishedSpans.push(spanToJSON(span)); + }); + }); + + afterEach(() => { + instrumentation.disable(); + cleanupOtel(); + }); + + function patch(fake: FakeMongoose, moduleVersion?: string): FakeMongoose { + const definition = instrumentation.getModuleDefinitions()[0]!; + return definition.patch!(fake, moduleVersion) as FakeMongoose; + } + + function unpatch(fake: FakeMongoose, moduleVersion?: string): void { + const definition = instrumentation.getModuleDefinitions()[0]!; + definition.unpatch!(fake, moduleVersion); + } + + function mongooseSpans(): SpanJSON[] { + return finishedSpans.filter(span => span.origin === ORIGIN); + } + + function spanByDescription(description: string): SpanJSON | undefined { + return mongooseSpans().find(span => span.description === description); + } + + describe('Model methods', () => { + it('creates a span for `save` with the expected name, attributes and origin', async () => { + const { Model } = patch(createFakeMongoose()); + + await Sentry.startSpan({ name: 'root' }, () => new Model().save()); + + const span = spanByDescription('mongoose.User.save'); + expect(span).toBeDefined(); + expect(span!.origin).toBe(ORIGIN); + expect(span!.data).toMatchObject({ + 'db.operation': 'save', + 'db.system': 'mongoose', + 'db.mongodb.collection': 'users', + 'db.name': 'test', + 'db.user': 'admin', + 'net.peer.name': 'localhost', + 'net.peer.port': 27017, + }); + // statement is never captured (no dbStatementSerializer) + expect(span!.data['db.statement']).toBeUndefined(); + expect(span!.data['db.query.text']).toBeUndefined(); + }); + + it('aliases `$save` to the patched `save`', () => { + const { Model } = patch(createFakeMongoose()); + expect(Model.prototype.$save).toBe(Model.prototype.save); + }); + + it('supports the callback signature', async () => { + const { Model } = patch(createFakeMongoose()); + + const response = await new Promise((resolve, reject) => { + Sentry.startSpan({ name: 'root' }, () => { + new Model().save((err: Error | null, res?: unknown) => (err ? reject(err) : resolve(res))); + }); + }); + + expect(response).toBe('result'); + expect(spanByDescription('mongoose.User.save')).toBeDefined(); + }); + + it('sets error status when the operation rejects', async () => { + const { Model } = patch(createFakeMongoose({ reject: true })); + + await Sentry.startSpan({ name: 'root' }, () => new Model().save()).catch(() => undefined); + + const span = spanByDescription('mongoose.User.save'); + expect(span).toBeDefined(); + expect(span!.status).toContain('boom'); + }); + }); + + describe('Model statics', () => { + it.each([ + ['insertMany', () => undefined], + ['bulkWrite', () => undefined], + ])('creates a span for `%s`', async opName => { + const fake = patch(createFakeMongoose()); + + await Sentry.startSpan({ name: 'root' }, () => fake.Model[opName]([{ name: 'a' }])); + + expect(spanByDescription(`mongoose.User.${opName}`)).toBeDefined(); + }); + + it('creates a span for `aggregate` and links it to the build-time span', async () => { + const fake = patch(createFakeMongoose()); + + let rootSpanId: string | undefined; + const aggregate = Sentry.startSpan({ name: 'root' }, root => { + rootSpanId = root.spanContext().spanId; + return fake.Model.aggregate(); + }); + + await aggregate.exec(); + + const span = spanByDescription('mongoose.User.aggregate'); + expect(span).toBeDefined(); + expect(span!.parent_span_id).toBe(rootSpanId); + }); + }); + + describe('Query exec', () => { + it('creates a span named after the query op', async () => { + const { Query } = patch(createFakeMongoose()); + + await Sentry.startSpan({ name: 'root' }, () => new Query('findOne').exec()); + + const span = spanByDescription('mongoose.User.findOne'); + expect(span).toBeDefined(); + expect(span!.data['db.operation']).toBe('findOne'); + }); + + it('links exec to the span captured when the query was built', async () => { + const { Query } = patch(createFakeMongoose()); + const query = new Query('findOne'); + + let rootSpanId: string | undefined; + Sentry.startSpan({ name: 'root' }, root => { + rootSpanId = root.spanContext().spanId; + // context-capture function stores the active span on the query + query.find(); + }); + + // exec runs outside the originating span but should still parent to it + await query.exec(); + + const span = spanByDescription('mongoose.User.findOne'); + expect(span).toBeDefined(); + expect(span!.parent_span_id).toBe(rootSpanId); + }); + + it('does not double-instrument a query already instrumented by a document method', async () => { + const { Query } = patch(createFakeMongoose()); + const query = new Query('updateOne'); + (query as any)[_ALREADY_INSTRUMENTED] = true; + + await Sentry.startSpan({ name: 'root' }, () => query.exec()); + + expect(spanByDescription('mongoose.User.updateOne')).toBeUndefined(); + }); + }); + + describe('document update methods (Mongoose 8.21.0+)', () => { + it('patches `updateOne`/`deleteOne` and tags the returned query', async () => { + const { Model } = patch(createFakeMongoose(), '8.21.0'); + + const query = await Sentry.startSpan({ name: 'root' }, () => new Model().updateOne()); + + expect(spanByDescription('mongoose.User.updateOne')).toBeDefined(); + // returned Query is marked so its eventual exec() is not instrumented again + expect((query as any)[_ALREADY_INSTRUMENTED]).toBe(true); + }); + + it('does not patch document methods for versions below 8.21.0', async () => { + const { Model } = patch(createFakeMongoose(), '8.20.0'); + + await Sentry.startSpan({ name: 'root' }, () => new Model().updateOne()); + + expect(spanByDescription('mongoose.User.updateOne')).toBeUndefined(); + }); + }); + + describe('remove (Mongoose 5/6)', () => { + it('patches `remove` on v6', async () => { + const { Model } = patch(createFakeMongoose(), '6.0.0'); + + await Sentry.startSpan({ name: 'root' }, () => new Model().remove()); + + expect(spanByDescription('mongoose.User.remove')).toBeDefined(); + }); + + it('does not patch `remove` on v8', async () => { + const { Model } = patch(createFakeMongoose(), '8.0.0'); + + await Sentry.startSpan({ name: 'root' }, () => new Model().remove()); + + expect(spanByDescription('mongoose.User.remove')).toBeUndefined(); + }); + }); + + describe('unpatch', () => { + it('stops creating spans after unpatch', async () => { + const fake = patch(createFakeMongoose()); + unpatch(fake); + + await Sentry.startSpan({ name: 'root' }, () => new fake.Model().save()); + + expect(mongooseSpans()).toHaveLength(0); + }); + }); +}); From 1349dd53eda984a4e6b488da08a569bca15f699a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 16:00:26 -0400 Subject: [PATCH 06/16] ref(node): Type mongoose module exports and satisfy lint Type the vendored instrumentation's `module` parameter (MongooseModule / MongooseModuleExports) instead of `any`, so the patch/unpatch logic is checked against the mongoose shape. Adjust lint config accordingly. --- .../tracing/mongoose/vendored/mongoose.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index cfa831b6f7a7..a69bb17985ad 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -20,24 +20,32 @@ * - 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 { SpanKind } from '@opentelemetry/api'; import { InstrumentationBase, - InstrumentationModuleDefinition, + type InstrumentationModuleDefinition, InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { Span, SpanAttributes } from '@sentry/core'; import { getActiveSpan, SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; import type * as mongoose from './mongoose-types'; import { ATTR_DB_OPERATION, ATTR_DB_SYSTEM } from './semconv'; -import { MongooseInstrumentationConfig } from './types'; +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', 'deleteMany', @@ -111,8 +119,9 @@ export class MongooseInstrumentation extends InstrumentationBase { return function patchedStatic(this: any, docsOrOps: any, options?: any, callback?: Function) { if (typeof options === 'function') { + // oxlint-disable-next-line no-param-reassign callback = options; } @@ -278,7 +290,7 @@ export class MongooseInstrumentation extends InstrumentationBase { return function captureSpanContext(this: any) { this[_STORED_PARENT_SPAN] = getActiveSpan(); From 5ef8d037fe9c64555d1fb2ceb5bf0fa357b20c0c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 11 Jun 2026 16:14:17 -0400 Subject: [PATCH 07/16] test(node): Cover aggregate/insertMany/bulkWrite in mongoose integration suite Exercise more mongoose operations against the real mongodb-memory-server so the live suite asserts span output (name, OLD attributes, op, origin) for aggregate, insertMany and bulkWrite in addition to save/findOne. --- .../suites/tracing/mongoose/scenario.mjs | 6 ++++ .../suites/tracing/mongoose/test.ts | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) 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..eb8a0933aaa6 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,12 @@ async function run() { 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() } } }]); }, ); } 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..e4796dff91db 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,39 @@ 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', + }), ]), }; From 09d476cfd8fd856d0eadb9c43aba74cfac27ad63 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 09:26:10 -0400 Subject: [PATCH 08/16] ref(node): Nest mongodb driver spans under mongoose spans Wrap the mongoose operation in `withActiveSpan` so the span is active while the underlying driver call runs. This parents the mongodb driver spans under the corresponding mongoose span (e.g. mongoose.User.save -> mongodb insert) instead of leaving them as siblings. `withActiveSpan` returns the callback result untouched, so it does not call `.then()` on lazy mongoose Query thenables (unlike startSpan/startSpanManual, which would execute them prematurely). The active window is synchronous-only, so unrelated spans are not parented to the mongoose span. --- .../tracing/mongoose/vendored/mongoose.ts | 25 +++++++++++++------ .../integrations/tracing/mongoose.test.ts | 17 +++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index a69bb17985ad..27a2d467bf04 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -28,7 +28,13 @@ import { InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; import type { Span, SpanAttributes } from '@sentry/core'; -import { getActiveSpan, SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } 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'; @@ -316,11 +322,16 @@ export class MongooseInstrumentation extends InstrumentationBase { + 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/test/integrations/tracing/mongoose.test.ts b/packages/node/test/integrations/tracing/mongoose.test.ts index 2bc419e310af..f9768e4c73ff 100644 --- a/packages/node/test/integrations/tracing/mongoose.test.ts +++ b/packages/node/test/integrations/tracing/mongoose.test.ts @@ -208,6 +208,23 @@ describe('mongoose instrumentation', () => { }); }); + describe('active span', () => { + it('runs the underlying operation with the mongoose span active so nested work nests under it', async () => { + const fake = createFakeMongoose(); + let activeDescription: string | undefined; + // emulate the underlying driver reading the active span while the operation runs + (fake.Model.prototype as any).save = function () { + activeDescription = spanToJSON(Sentry.getActiveSpan()!).description; + return Promise.resolve('ok'); + }; + patch(fake); + + await Sentry.startSpan({ name: 'root' }, () => new fake.Model().save()); + + expect(activeDescription).toBe('mongoose.User.save'); + }); + }); + describe('Model statics', () => { it.each([ ['insertMany', () => undefined], From 4da6f23b9d309e9571e55999d851a35384255af2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 09:37:13 -0400 Subject: [PATCH 09/16] test(node): Add mongoose version matrix and lazy-Query regression guard Add integration suites pinning mongoose 7, 8 and 9 (alongside the existing v6 suite) so every supported version branch is exercised against a real mongoose: contextCaptureFunctions7 (v7), the 8.21+ document-method path (v8) and the latest major (v9). The v8 suite guards the lazy-Query trap directly: it builds a document `updateOne` without awaiting it and asserts the document is not modified. The instrumentation must hand the lazy Query back un-executed; running it (as `startSpan`/`startSpanManual` would, by calling `.then()` on the returned thenable) causes a premature write that fails the test. --- .../suites/tracing/mongoose-v7/instrument.mjs | 9 +++ .../suites/tracing/mongoose-v7/scenario.mjs | 36 ++++++++++ .../suites/tracing/mongoose-v7/test.ts | 55 +++++++++++++++ .../suites/tracing/mongoose-v8/instrument.mjs | 9 +++ .../suites/tracing/mongoose-v8/scenario.mjs | 56 +++++++++++++++ .../suites/tracing/mongoose-v8/test.ts | 69 +++++++++++++++++++ .../suites/tracing/mongoose-v9/instrument.mjs | 9 +++ .../suites/tracing/mongoose-v9/scenario.mjs | 36 ++++++++++ .../suites/tracing/mongoose-v9/test.ts | 56 +++++++++++++++ 9 files changed, 335 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v7/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v7/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v8/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v8/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v9/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v9/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts 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..0463ed0b3680 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/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-v9/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts new file mode 100644 index 000000000000..03d10c1d2040 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts @@ -0,0 +1,56 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +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. +describe('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'), + ]), + }; + + 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' } }, + ); +}); From 6df56a1d4ea16604e88aac78749883455a373080 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 10:00:22 -0400 Subject: [PATCH 10/16] test(node): Cover mongoose callback signature against real v6 The instrumentation supports the callback form (mongoose 5/6) by forwarding the original `arguments` and swapping the callback slot by position. Exercise `save(callback)` end-to-end against real mongoose 6 and assert the callback receives the saved document, so the positional handling is covered (the unit test's fake finds the callback by type, not position). --- .../suites/tracing/mongoose/scenario.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 eb8a0933aaa6..b10b04cd3250 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs @@ -29,6 +29,20 @@ async function run() { 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() }]); From 7d8cc89f4ad40a4d048c6a34b268944c88e952f3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 11:56:02 -0400 Subject: [PATCH 11/16] test(node): Gate mongoose v9 integration suite to Node >=20 mongoose 9 requires Node >=20.19, so on the Node 18 CI job the v9 scenario crashed at runtime (save rejected, partial spans) and the suite failed. Wrap it in `conditionalTest({ min: 20 })` so it's skipped on older Node, matching mongoose 9's own engine constraint. --- .../suites/tracing/mongoose-v9/test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 03d10c1d2040..24e8ae2cd601 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts @@ -1,10 +1,11 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; -import { afterAll, beforeAll, describe, expect } from 'vitest'; +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. -describe('Mongoose v9 Test', () => { +// 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 () => { From 53b74cb2970b54eccc1bc9e14e2cda88167b6a8d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 13:28:28 -0400 Subject: [PATCH 12/16] test(node): Drop fake mongoose unit suite in favor of real integration tests The fake-module unit suite mostly duplicated operation/attribute coverage now provided by the real mongoose v6/v7/v8/v9 integration suites, plus some internal-only checks (version gating, unpatch, $save aliasing). Behavior is better verified against real mongoose, so remove the fake suite. --- .../integrations/tracing/mongoose.test.ts | 346 ------------------ 1 file changed, 346 deletions(-) delete mode 100644 packages/node/test/integrations/tracing/mongoose.test.ts diff --git a/packages/node/test/integrations/tracing/mongoose.test.ts b/packages/node/test/integrations/tracing/mongoose.test.ts deleted file mode 100644 index f9768e4c73ff..000000000000 --- a/packages/node/test/integrations/tracing/mongoose.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -/* - * Tests ported from @opentelemetry/instrumentation-mongoose@0.64.0 - * Original source: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-mongoose - * Licensed under the Apache License, Version 2.0 - * - * The upstream suite runs against a real mongoose + mongodb. Here we exercise the - * same operation coverage against a fake mongoose module so the instrumentation - * logic (span name, attributes, origin, error status, parent linking, patch/unpatch) - * can be unit tested without a database. - */ - -import type { SpanJSON } from '@sentry/core'; -import { getClient, spanToJSON } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as Sentry from '../../../src'; -import { - _ALREADY_INSTRUMENTED, - MongooseInstrumentation, -} from '../../../src/integrations/tracing/mongoose/vendored/mongoose'; -import { cleanupOtel, mockSdkInit } from '../../helpers/mockSdkInit'; - -type AnyFn = (...args: any[]) => any; - -const ORIGIN = 'auto.db.otel.mongoose'; - -const CONN = { name: 'test', host: 'localhost', port: 27017, user: 'admin' }; -const COLLECTION = { name: 'users', conn: CONN }; - -// Names the instrumentation wraps on `Query.prototype` to capture the parent span. -const CONTEXT_CAPTURE_FUNCTIONS = [ - 'deleteOne', - 'deleteMany', - 'find', - 'findOne', - 'estimatedDocumentCount', - 'countDocuments', - 'distinct', - 'where', - '$where', - 'findOneAndUpdate', - 'findOneAndDelete', - 'findOneAndReplace', -]; - -// Returns an implementation that resolves/rejects as a promise, or invokes a trailing callback. -function fakeOp({ result = 'result', reject = false }: { result?: unknown; reject?: boolean } = {}): AnyFn { - return function (this: unknown, ...args: unknown[]): unknown { - const callback = args.find(arg => typeof arg === 'function') as AnyFn | undefined; - const error = reject ? Object.assign(new Error('boom'), { code: 123 }) : null; - - if (callback) { - callback(error, reject ? undefined : result); - return undefined; - } - - return reject ? Promise.reject(error) : Promise.resolve(result); - }; -} - -interface FakeMongoose { - Model: any; - Query: any; - Aggregate: any; -} - -function createFakeMongoose({ reject = false }: { reject?: boolean } = {}): FakeMongoose { - // NOTE: methods the instrumentation patches must live on the prototype (or the - // constructor for statics), since `_wrap` operates on prototype/static members. - class Query { - public op: string; - public mongooseCollection = COLLECTION; - public model = { modelName: 'User' }; - - public constructor(op: string) { - this.op = op; - } - } - - (Query.prototype as any).exec = fakeOp({ reject }); - // chainable context-capture functions all return `this` - CONTEXT_CAPTURE_FUNCTIONS.forEach(name => { - (Query.prototype as any)[name] = function (this: unknown) { - return this; - }; - }); - - class Aggregate { - public _model = { collection: COLLECTION, modelName: 'User' }; - public _pipeline: unknown[] = []; - public options = {}; - } - - (Aggregate.prototype as any).exec = fakeOp({ reject }); - - class Model { - public static collection = COLLECTION; - public static modelName = 'User'; - - public static aggregate(): Aggregate { - return new Aggregate(); - } - - public static insertMany = fakeOp({ reject, result: [] }); - public static bulkWrite = fakeOp({ reject, result: {} }); - - // Document instance methods (Mongoose 8.21.0+) return a Query. - public updateOne(): Query { - return new Query('updateOne'); - } - public deleteOne(): Query { - return new Query('deleteOne'); - } - } - - (Model.prototype as any).save = fakeOp({ reject }); - // Removed in Mongoose 7+, only patched for v5/v6. - (Model.prototype as any).remove = fakeOp({ reject }); - - return { Model, Query, Aggregate }; -} - -describe('mongoose instrumentation', () => { - let instrumentation: MongooseInstrumentation; - let finishedSpans: SpanJSON[]; - - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - instrumentation = new MongooseInstrumentation(); - - finishedSpans = []; - getClient()?.on('spanEnd', span => { - finishedSpans.push(spanToJSON(span)); - }); - }); - - afterEach(() => { - instrumentation.disable(); - cleanupOtel(); - }); - - function patch(fake: FakeMongoose, moduleVersion?: string): FakeMongoose { - const definition = instrumentation.getModuleDefinitions()[0]!; - return definition.patch!(fake, moduleVersion) as FakeMongoose; - } - - function unpatch(fake: FakeMongoose, moduleVersion?: string): void { - const definition = instrumentation.getModuleDefinitions()[0]!; - definition.unpatch!(fake, moduleVersion); - } - - function mongooseSpans(): SpanJSON[] { - return finishedSpans.filter(span => span.origin === ORIGIN); - } - - function spanByDescription(description: string): SpanJSON | undefined { - return mongooseSpans().find(span => span.description === description); - } - - describe('Model methods', () => { - it('creates a span for `save` with the expected name, attributes and origin', async () => { - const { Model } = patch(createFakeMongoose()); - - await Sentry.startSpan({ name: 'root' }, () => new Model().save()); - - const span = spanByDescription('mongoose.User.save'); - expect(span).toBeDefined(); - expect(span!.origin).toBe(ORIGIN); - expect(span!.data).toMatchObject({ - 'db.operation': 'save', - 'db.system': 'mongoose', - 'db.mongodb.collection': 'users', - 'db.name': 'test', - 'db.user': 'admin', - 'net.peer.name': 'localhost', - 'net.peer.port': 27017, - }); - // statement is never captured (no dbStatementSerializer) - expect(span!.data['db.statement']).toBeUndefined(); - expect(span!.data['db.query.text']).toBeUndefined(); - }); - - it('aliases `$save` to the patched `save`', () => { - const { Model } = patch(createFakeMongoose()); - expect(Model.prototype.$save).toBe(Model.prototype.save); - }); - - it('supports the callback signature', async () => { - const { Model } = patch(createFakeMongoose()); - - const response = await new Promise((resolve, reject) => { - Sentry.startSpan({ name: 'root' }, () => { - new Model().save((err: Error | null, res?: unknown) => (err ? reject(err) : resolve(res))); - }); - }); - - expect(response).toBe('result'); - expect(spanByDescription('mongoose.User.save')).toBeDefined(); - }); - - it('sets error status when the operation rejects', async () => { - const { Model } = patch(createFakeMongoose({ reject: true })); - - await Sentry.startSpan({ name: 'root' }, () => new Model().save()).catch(() => undefined); - - const span = spanByDescription('mongoose.User.save'); - expect(span).toBeDefined(); - expect(span!.status).toContain('boom'); - }); - }); - - describe('active span', () => { - it('runs the underlying operation with the mongoose span active so nested work nests under it', async () => { - const fake = createFakeMongoose(); - let activeDescription: string | undefined; - // emulate the underlying driver reading the active span while the operation runs - (fake.Model.prototype as any).save = function () { - activeDescription = spanToJSON(Sentry.getActiveSpan()!).description; - return Promise.resolve('ok'); - }; - patch(fake); - - await Sentry.startSpan({ name: 'root' }, () => new fake.Model().save()); - - expect(activeDescription).toBe('mongoose.User.save'); - }); - }); - - describe('Model statics', () => { - it.each([ - ['insertMany', () => undefined], - ['bulkWrite', () => undefined], - ])('creates a span for `%s`', async opName => { - const fake = patch(createFakeMongoose()); - - await Sentry.startSpan({ name: 'root' }, () => fake.Model[opName]([{ name: 'a' }])); - - expect(spanByDescription(`mongoose.User.${opName}`)).toBeDefined(); - }); - - it('creates a span for `aggregate` and links it to the build-time span', async () => { - const fake = patch(createFakeMongoose()); - - let rootSpanId: string | undefined; - const aggregate = Sentry.startSpan({ name: 'root' }, root => { - rootSpanId = root.spanContext().spanId; - return fake.Model.aggregate(); - }); - - await aggregate.exec(); - - const span = spanByDescription('mongoose.User.aggregate'); - expect(span).toBeDefined(); - expect(span!.parent_span_id).toBe(rootSpanId); - }); - }); - - describe('Query exec', () => { - it('creates a span named after the query op', async () => { - const { Query } = patch(createFakeMongoose()); - - await Sentry.startSpan({ name: 'root' }, () => new Query('findOne').exec()); - - const span = spanByDescription('mongoose.User.findOne'); - expect(span).toBeDefined(); - expect(span!.data['db.operation']).toBe('findOne'); - }); - - it('links exec to the span captured when the query was built', async () => { - const { Query } = patch(createFakeMongoose()); - const query = new Query('findOne'); - - let rootSpanId: string | undefined; - Sentry.startSpan({ name: 'root' }, root => { - rootSpanId = root.spanContext().spanId; - // context-capture function stores the active span on the query - query.find(); - }); - - // exec runs outside the originating span but should still parent to it - await query.exec(); - - const span = spanByDescription('mongoose.User.findOne'); - expect(span).toBeDefined(); - expect(span!.parent_span_id).toBe(rootSpanId); - }); - - it('does not double-instrument a query already instrumented by a document method', async () => { - const { Query } = patch(createFakeMongoose()); - const query = new Query('updateOne'); - (query as any)[_ALREADY_INSTRUMENTED] = true; - - await Sentry.startSpan({ name: 'root' }, () => query.exec()); - - expect(spanByDescription('mongoose.User.updateOne')).toBeUndefined(); - }); - }); - - describe('document update methods (Mongoose 8.21.0+)', () => { - it('patches `updateOne`/`deleteOne` and tags the returned query', async () => { - const { Model } = patch(createFakeMongoose(), '8.21.0'); - - const query = await Sentry.startSpan({ name: 'root' }, () => new Model().updateOne()); - - expect(spanByDescription('mongoose.User.updateOne')).toBeDefined(); - // returned Query is marked so its eventual exec() is not instrumented again - expect((query as any)[_ALREADY_INSTRUMENTED]).toBe(true); - }); - - it('does not patch document methods for versions below 8.21.0', async () => { - const { Model } = patch(createFakeMongoose(), '8.20.0'); - - await Sentry.startSpan({ name: 'root' }, () => new Model().updateOne()); - - expect(spanByDescription('mongoose.User.updateOne')).toBeUndefined(); - }); - }); - - describe('remove (Mongoose 5/6)', () => { - it('patches `remove` on v6', async () => { - const { Model } = patch(createFakeMongoose(), '6.0.0'); - - await Sentry.startSpan({ name: 'root' }, () => new Model().remove()); - - expect(spanByDescription('mongoose.User.remove')).toBeDefined(); - }); - - it('does not patch `remove` on v8', async () => { - const { Model } = patch(createFakeMongoose(), '8.0.0'); - - await Sentry.startSpan({ name: 'root' }, () => new Model().remove()); - - expect(spanByDescription('mongoose.User.remove')).toBeUndefined(); - }); - }); - - describe('unpatch', () => { - it('stops creating spans after unpatch', async () => { - const fake = patch(createFakeMongoose()); - unpatch(fake); - - await Sentry.startSpan({ name: 'root' }, () => new fake.Model().save()); - - expect(mongooseSpans()).toHaveLength(0); - }); - }); -}); From 4e322158b56318bb30990ff9a17518a5da1f6b97 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 13:48:28 -0400 Subject: [PATCH 13/16] test(node): Cover failing mongoose operation produces an error span Add a failing (validation) save to the v6 integration scenario and assert it still produces a `mongoose.RequiredDoc.save` span with the expected origin and an error status. This restores the error-path coverage previously held by the removed fake unit suite, now verified against real mongoose. --- .../suites/tracing/mongoose/scenario.mjs | 6 ++++++ .../suites/tracing/mongoose/test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) 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 b10b04cd3250..9ca713cd2c3f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs @@ -48,6 +48,12 @@ async function run() { await BlogPost.insertMany([{ title: 'Insert', body: 'Insert body', date: new Date() }]); await BlogPost.bulkWrite([{ insertOne: { document: { title: 'Bulk', body: 'Bulk body', date: new Date() } } }]); + + // 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 e4796dff91db..54ce6fc9250f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -75,6 +75,17 @@ describe('Mongoose experimental Test', () => { 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', + }), ]), }; From cc3dcd72298de2a803fb15e1de4fd18b6da88d50 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 14:04:39 -0400 Subject: [PATCH 14/16] test(node): Lock mongoose span nesting and cover v9 document methods - Assert the mongodb driver span is parented under the mongoose span (the withActiveSpan behavior) in the v6 suite, which runs on all supported Node versions. - Exercise document updateOne/deleteOne on mongoose 9 and assert their spans. On v9 these aren't doc-method-patched (needsDocumentMethodPatch only matches 8.x) but are still correctly instrumented via the patched Query.exec path. --- .../suites/tracing/mongoose-v9/scenario.mjs | 6 ++++++ .../suites/tracing/mongoose-v9/test.ts | 3 +++ .../suites/tracing/mongoose/test.ts | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+) 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 index 0463ed0b3680..cfdacfd08646 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/scenario.mjs @@ -29,6 +29,12 @@ async function run() { 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(); }, ); } 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 index 24e8ae2cd601..3b2b48882254 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts @@ -40,6 +40,9 @@ conditionalTest({ min: 20 })('Mongoose v9 Test', () => { expectedSpan('aggregate'), expectedSpan('insertMany'), expectedSpan('bulkWrite'), + // Document instance methods are instrumented via Query.exec on v9 (no doc-method patch). + expectedSpan('updateOne'), + expectedSpan('deleteOne'), ]), }; 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 54ce6fc9250f..a42c48d4ec43 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -93,5 +93,23 @@ 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(); + }); }); }); From 2477a59d55a1a70184353c4886ca6253666c59e9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 12 Jun 2026 19:17:34 -0400 Subject: [PATCH 15/16] test(node): Cover mongoose `remove` and cross-context query parenting - Exercise the document `remove` method (patched only on mongoose 5/6) and assert its span. - Build a query inside one span and await it after that span ends, asserting the exec span parents to the span it was built in (the _STORED_PARENT_SPAN mechanism) rather than the active span at execution time. --- .../suites/tracing/mongoose/scenario.mjs | 12 +++++++ .../suites/tracing/mongoose/test.ts | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+) 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 9ca713cd2c3f..94eb43482d4e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/scenario.mjs @@ -49,6 +49,18 @@ async function run() { 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 } }); 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 a42c48d4ec43..ed20aff13496 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -75,6 +75,18 @@ describe('Mongoose experimental Test', () => { 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({ @@ -111,5 +123,24 @@ describe('Mongoose experimental Test', () => { .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(); + }); }); }); From 053fcd199db8ddf83301f96d8e33661a5f8837aa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 15 Jun 2026 13:26:11 -0400 Subject: [PATCH 16/16] ref(node): Drop trivial mongoose types.ts, inline InstrumentationConfig `MongooseInstrumentationConfig` had collapsed to a bare `= InstrumentationConfig` alias used only inside the vendored instrumentation. Inline `InstrumentationConfig` directly and remove the now-pointless types.ts file. --- .../tracing/mongoose/vendored/mongoose.ts | 6 ++--- .../tracing/mongoose/vendored/types.ts | 23 ------------------- 2 files changed, 3 insertions(+), 26 deletions(-) delete mode 100644 packages/node/src/integrations/tracing/mongoose/vendored/types.ts diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index 27a2d467bf04..1238b42c510a 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -23,6 +23,7 @@ import { SpanKind } from '@opentelemetry/api'; import { + type InstrumentationConfig, InstrumentationBase, type InstrumentationModuleDefinition, InstrumentationNodeModuleDefinition, @@ -37,7 +38,6 @@ import { } 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'; @@ -110,8 +110,8 @@ export const _STORED_PARENT_SPAN: unique symbol = Symbol('stored-parent-span'); // creates a span and returns a Query that also calls exec() export const _ALREADY_INSTRUMENTED: unique symbol = Symbol('already-instrumented'); -export class MongooseInstrumentation extends InstrumentationBase { - constructor(config: MongooseInstrumentationConfig = {}) { +export class MongooseInstrumentation extends InstrumentationBase { + constructor(config: InstrumentationConfig = {}) { super(PACKAGE_NAME, SDK_VERSION, config); } diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts b/packages/node/src/integrations/tracing/mongoose/vendored/types.ts deleted file mode 100644 index 13443e7daba2..000000000000 --- a/packages/node/src/integrations/tracing/mongoose/vendored/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongoose - * - Upstream version: @opentelemetry/instrumentation-mongoose@0.64.0 - */ - -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; - -export type MongooseInstrumentationConfig = InstrumentationConfig;