diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/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-v5/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs new file mode 100644 index 000000000000..0463ed0b3680 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/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-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts new file mode 100644 index 000000000000..9f62692c1b90 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts @@ -0,0 +1,59 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongoose 5.9.7 +// the bottom of the IITM patcher's `>=5.9.7 <9.7.0` range, so the oldest +// supported major is exercised against a real mongoose. +describe('Mongoose v5 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; + 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, + }); + + 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` v5.', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }, + { additionalDependencies: { mongoose: '^5.9.7' } }, + ); +}); 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 index dde237210761..ee9cec92cc46 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts @@ -1,9 +1,11 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins mongoose 7 so the `contextCaptureFunctions7` version branch is exercised against a real mongoose. describe('Mongoose v7 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -27,7 +29,7 @@ describe('Mongoose v7 Test', () => { }), description: `mongoose.BlogPost.${operation}`, op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }); const EXPECTED_TRANSACTION = { 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 index 48e3faefbf0c..b13d715fc3bd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts @@ -1,10 +1,12 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; 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', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -30,7 +32,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -40,7 +42,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.updateOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -50,7 +52,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.deleteOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), ]), }; 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 85f5c432983e..cf027f31fc38 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,6 +1,6 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins the highest mongoose 9 below 9.7, the top of the IITM patcher's `>=5.9.7 <9.7.0` range, so the @@ -8,6 +8,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // diagnostics_channel and is covered by the `mongoose-tracing-channel` suite instead. // mongoose 9 requires Node >=20.19, so this suite is skipped on older Node. conditionalTest({ min: 20 })('Mongoose v9 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -31,7 +32,7 @@ conditionalTest({ min: 20 })('Mongoose v9 Test', () => { }), description: `mongoose.BlogPost.${operation}`, op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }); const EXPECTED_TRANSACTION = { 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 ed20aff13496..b3a2b709c701 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -1,8 +1,10 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('Mongoose experimental Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -29,7 +31,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -40,7 +42,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.findOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -51,7 +53,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.aggregate', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -62,7 +64,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.insertMany', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -73,7 +75,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.bulkWrite', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), // `remove` is patched only on mongoose 5/6. expect.objectContaining({ @@ -85,7 +87,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.remove', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), // A failing operation still produces a span, marked with an error status. expect.objectContaining({ @@ -95,7 +97,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.RequiredDoc.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, status: 'internal_error', }), ]), diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index e87686c84d51..a2f8e3ca65cf 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -114,6 +114,7 @@ export type { DenoRedisIntegrationOptions } from './integrations/redis'; export { denoMysqlIntegration } from './integrations/mysql'; export { denoPostgresIntegration } from './integrations/postgres'; export { denoAmqplibIntegration } from './integrations/amqplib'; +export { denoMongooseIntegration } from './integrations/mongoose'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; export { normalizePathsIntegration } from './integrations/normalizepaths'; diff --git a/packages/deno/src/integrations/mongoose.ts b/packages/deno/src/integrations/mongoose.ts new file mode 100644 index 000000000000..86e079cd37e6 --- /dev/null +++ b/packages/deno/src/integrations/mongoose.ts @@ -0,0 +1,34 @@ +import { mongooseChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoMongoose' as const; + +/** + * Create spans for `mongoose` queries under Deno. + * + * `mongoose` channels are injected by the orchestrion runtime hook at load + * time. The `@sentry/deno/import` loader must be active for this integration + * to record anything. + * + * The channel-subscription logic is shared with the other server runtimes in + * `@sentry/server-utils`. This just installs Deno's `AsyncLocalStorage` + * context strategy (so spans nest under the active span and survive mongoose's + * internal callback dispatch) before delegating. + */ +const _denoMongooseIntegration = (() => { + const inner = mongooseChannelIntegration(); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoMongooseIntegration = defineIntegration(_denoMongooseIntegration) as () => Integration & { + name: 'DenoMongoose'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index f67b545966de..317a8f48d082 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -24,6 +24,7 @@ import { import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; import { denoAmqplibIntegration } from './integrations/amqplib'; +import { denoMongooseIntegration } from './integrations/mongoose'; import { denoMysqlIntegration } from './integrations/mysql'; import { denoPostgresIntegration } from './integrations/postgres'; import { denoRedisIntegration } from './integrations/redis'; @@ -62,7 +63,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { // (or in parallel to) loading the SDK, so we only gate on whether the // feature is possible. If they're never loaded, it'll just be a no-op. ...(MODULE_REGISTER_HOOKS_SUPPORTED - ? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration()] + ? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration(), denoMongooseIntegration()] : []), contextLinesIntegration(), normalizePathsIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 3b344fa68eff..503924b0107e 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -118,6 +118,7 @@ snapshot[`captureException 1`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoMongoose", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -196,6 +197,7 @@ snapshot[`captureMessage 1`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoMongoose", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -281,6 +283,7 @@ snapshot[`captureMessage twice 1`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoMongoose", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -373,6 +376,7 @@ snapshot[`captureMessage twice 2`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoMongoose", "ContextLines", "NormalizePaths", "GlobalHandlers", diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index 8144b38ef801..579408acf6c5 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -16,18 +16,11 @@ import { type InstrumentationModuleDefinition, InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; -import { DB_OPERATION, DB_SYSTEM } from '@sentry/conventions/attributes'; -import type { Span, SpanAttributes } from '@sentry/core'; -import { - getActiveSpan, - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_KIND, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { getActiveSpan, SDK_VERSION, withActiveSpan } from '@sentry/core'; +import { startMongooseLegacySpan } from '@sentry/server-utils'; import type * as mongoose from './mongoose-types'; -import { getAttributesFromCollection, handleCallbackResponse, handlePromiseResponse } from './utils'; +import { handleCallbackResponse, handlePromiseResponse } from './utils'; const PACKAGE_NAME = '@sentry/instrumentation-mongoose'; const ORIGIN = 'auto.db.otel.mongoose'; @@ -190,7 +183,13 @@ export class MongooseInstrumentation extends InstrumentationBase { return function exec(this: any, callback?: Function) { const parentSpan = this[_STORED_PARENT_SPAN]; - const span = self._startSpan(this._model.collection, this._model?.modelName, 'aggregate', parentSpan); + const span = startMongooseLegacySpan({ + collection: this._model.collection, + modelName: this._model?.modelName, + operation: 'aggregate', + origin: ORIGIN, + parentSpan, + }); return self._handleResponse(span, originalAggregate, this, arguments, callback); }; @@ -207,7 +206,13 @@ export class MongooseInstrumentation extends InstrumentationBase { return function method(this: any, options?: any, callback?: Function) { - const span = self._startSpan(this.constructor.collection, this.constructor.modelName, op); + const span = startMongooseLegacySpan({ + collection: this.constructor.collection, + modelName: this.constructor.modelName, + operation: op, + origin: ORIGIN, + }); if (options instanceof Function) { // oxlint-disable-next-line no-param-reassign @@ -243,7 +253,12 @@ export class MongooseInstrumentation extends InstrumentationBase(); + +let orchestrionSubscribed = false; + +const _mongooseChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + // mongoose >= 9.7 publishes via its own diagnostics_channel; reuse + // the shared native subscriber so this single integration covers all + // versions after it replaces the OTel one. Idempotent and inert on + // < 9.7 (those channels just never get published to) + subscribeMongooseDiagnosticChannels(diagnosticsChannel.tracingChannel); + + // mongoose < 9.7 is covered by the orchestrion-injected channels. + subscribeOrchestrionMongooseChannels(); + }); + }, + }; +}) satisfies IntegrationFn; + +function subscribeOrchestrionMongooseChannels(): void { + if (orchestrionSubscribed) { + return; + } + orchestrionSubscribed = true; + + DEBUG_BUILD && debug.log('[orchestrion:mongoose] subscribing to injected channels'); + + // Context-capture builders: stash the active span at build time so `exec()` + // can parent to it. + for (const channelName of MONGOOSE_CONTEXT_CAPTURE_CHANNELS) { + channel(channelName).subscribe({ + start(message) { + stashParentSpan(message.self); + }, + }); + } + + // `Model.aggregate()` returns the aggregate it builds; stash the parent on + // the returned object. + channel(CHANNELS.MONGOOSE_MODEL_AGGREGATE).subscribe({ + end(message) { + const result = message.result; + if (result && typeof result === 'object') { + stashParentSpan(result); + } + }, + }); + + // Query execution. + bindExecSpan(CHANNELS.MONGOOSE_QUERY_EXEC, self => { + const query = self as MongooseQuery; + return startSpan( + query.mongooseCollection, + query.model?.modelName, + query.op ?? 'exec', + STORED_PARENT_SPAN.get(self), + ); + }); + + // Aggregation execution. + bindExecSpan(CHANNELS.MONGOOSE_AGGREGATE_EXEC, self => { + const model = (self as MongooseAggregate)._model; + return startSpan(model?.collection, model?.modelName, 'aggregate', STORED_PARENT_SPAN.get(self)); + }); + + // `doc.save()` (and the `$save` alias). + bindExecSpan(CHANNELS.MONGOOSE_MODEL_SAVE, self => { + const ctor = (self as MongooseDocument).constructor; + return startSpan(ctor.collection, ctor.modelName, 'save'); + }); + + // `doc.remove()` (mongoose 5/6). + bindExecSpan(CHANNELS.MONGOOSE_MODEL_REMOVE, self => { + const ctor = (self as MongooseDocument).constructor; + return startSpan(ctor.collection, ctor.modelName, 'remove'); + }); + + // Static batch operations. `self` is the Model. + bindExecSpan(CHANNELS.MONGOOSE_MODEL_INSERT_MANY, self => { + const model = self as MongooseModelStatic; + return startSpan(model.collection, model.modelName, 'insertMany'); + }); + bindExecSpan(CHANNELS.MONGOOSE_MODEL_BULK_WRITE, self => { + const model = self as MongooseModelStatic; + return startSpan(model.collection, model.modelName, 'bulkWrite'); + }); +} + +function startSpan( + collection: MongooseLegacyCollection | undefined, + modelName: string | undefined, + operation: string, + parentSpan?: Span, +): Span { + return startMongooseLegacySpan({ collection, modelName, operation, origin: ORIGIN, parentSpan }); +} + +// `SentryTracingChannel` relaxes Node's subscriber type to a `Partial`, so a +// `start`-only (or `end`-only) subscriber for the context-capture channels +// is accepted. +function channel(channelName: string): SentryTracingChannel { + return diagnosticsChannel.tracingChannel( + channelName, + ) as unknown as SentryTracingChannel; +} + +function bindExecSpan(channelName: string, getSpan: (self: object) => Span): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + const self = data.self; + if (!self) { + return undefined; + } + return getSpan(self); + }, + ); +} + +function stashParentSpan(self: object | undefined): void { + const active = getActiveSpan(); + if (self && active) { + STORED_PARENT_SPAN.set(self, active); + } +} + +/** + * EXPERIMENTAL: orchestrion-driven mongoose integration. + * + * Reproduces the vendored `@opentelemetry/instrumentation-mongoose` span + * shape (legacy db/net semantic conventions, `mongoose..` names, + * build-time span parenting) via the `orchestrion:mongoose:*` + * diagnostics_channels injected into mongoose `< 9.7` by the orchestrion + * code transform. For mongoose `>= 9.7` it also drives the native + * diagnostics_channel subscription, so this single integration covers every + * supported version once it replaces the OTel one. + */ +export const mongooseChannelIntegration = defineIntegration(_mongooseChannelIntegration); diff --git a/packages/server-utils/src/mongoose/index.ts b/packages/server-utils/src/mongoose/index.ts index ace42bb7a122..c16ee5811751 100644 --- a/packages/server-utils/src/mongoose/index.ts +++ b/packages/server-utils/src/mongoose/index.ts @@ -2,6 +2,9 @@ import { defineIntegration, type IntegrationFn, waitForTracingChannelBinding } f import * as dc from 'node:diagnostics_channel'; import { subscribeMongooseDiagnosticChannels } from './mongoose-dc-subscriber'; +export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose-legacy-span'; +export { startMongooseLegacySpan } from './mongoose-legacy-span'; + const _mongooseIntegration = (() => { return { name: 'Mongoose', diff --git a/packages/server-utils/src/mongoose/mongoose-legacy-span.ts b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts new file mode 100644 index 000000000000..301b9be210d7 --- /dev/null +++ b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts @@ -0,0 +1,64 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core'; + +// OTel "OLD" db/net semantic-conventions, reproduced from the vendored +// `@opentelemetry/instrumentation-mongoose` span shape. Inlined as literals to +// avoid importing the deprecated convention constants. +const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_USER = 'db.user'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const ATTR_DB_OPERATION = 'db.operation'; +const ATTR_DB_SYSTEM = 'db.system'; + +/** The subset of mongoose's `Collection` that the legacy span shape reads. */ +export interface MongooseLegacyCollection { + name?: string; + conn?: { name?: string; user?: string; host?: string; port?: number }; +} + +export interface StartMongooseLegacySpanOptions { + collection: MongooseLegacyCollection | undefined; + modelName: string | undefined; + operation: string; + /** Span origin: distinguishes the OTel/IITM caller from orchestrion */ + origin: string; + parentSpan?: Span; +} + +/** + * Start a mongoose client span with the legacy (pre-stable) db/net semantic + * conventions. + * + * Shared by the vendored OTel/IITM instrumentation (`@sentry/node`) and the + * orchestrion channel subscriber so the two emit an identical span shape, + * differing only by `origin`. + */ +export function startMongooseLegacySpan({ + collection, + modelName, + operation, + origin, + parentSpan, +}: StartMongooseLegacySpanOptions): Span { + const attributes: SpanAttributes = { + [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, + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_SYSTEM]: 'mongoose', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + }; + + return startInactiveSpan({ + name: `mongoose.${modelName}.${operation}`, + // Set this explicitly, for platforms lacking `inferDbSpanData` + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes, + parentSpan, + }); +} diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index d3186a0aed57..1331ce38abef 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,6 +1,126 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -// TODO: Stub for the `mongoose` orchestrion integration (ports `@opentelemetry/instrumentation-mongoose`). -export const mongooseConfig: InstrumentationConfig[] = []; +// mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by +// `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting +// two spans per operation. The lower bound mirrors the vendored OTel IITM patcher's supported range. +const module = { name: 'mongoose', versionRange: '>=5.9.7 <9.7.0' } as const; -export const mongooseChannels = {} as const; +// mongoose defines its prototype/static methods as assignments +// (`Query.prototype.find = function(...)`, most of them anonymous), so the +// transform matches the assigned property name via `expressionName` +// rather than the function name. Entries whose method does not exist in a +// given major are simply inert. + +// Builder methods that run synchronously and return a `Query`/`Aggregate`. +// They don't get their own span; the subscriber only reads the active span +// at build time and stashes it on the query so the later `exec()` can parent +// to where the query was *built*, not where it is awaited. `kind: 'Sync'` +// so the returned thenable isn't mistaken for the traced operation's result. +const CONTEXT_CAPTURE_QUERY_METHODS = [ + 'find', + 'findOne', + 'deleteOne', + 'deleteMany', + 'estimatedDocumentCount', + 'countDocuments', + 'distinct', + 'where', + '$where', + 'findOneAndUpdate', + 'findOneAndDelete', + 'findOneAndReplace', + // 5/6/7 only (removed in 8), inert in recent versions + 'remove', + 'count', + 'findOneAndRemove', +] as const; + +export const mongooseConfig = [ + // Query execution + // the span for most read/write operations. `op`, collection and model are + // read off the `Query` at exec time. + { + channelName: 'query_exec', + module: { ...module, filePath: 'lib/query.js' }, + functionQuery: { expressionName: 'exec', kind: 'Auto' }, + }, + // Aggregation pipeline execution. + { + channelName: 'aggregate_exec', + module: { ...module, filePath: 'lib/aggregate.js' }, + functionQuery: { expressionName: 'exec', kind: 'Auto' }, + }, + // `doc.save()` (and its `$save` alias, which mongoose points at `save` on + // require. the alias picks up the transformed body automatically, so no + // separate entry is needed). + { + channelName: 'model_save', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'save', kind: 'Auto' }, + }, + // Static batch operations. + { + channelName: 'model_insert_many', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'insertMany', kind: 'Auto' }, + }, + { + channelName: 'model_bulk_write', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'bulkWrite', kind: 'Auto' }, + }, + // `doc.remove()` (a document method, deprecated in 6 and removed in 7) + // `expressionName: 'remove'` also matches the sibling `Model.remove` + // *static* in this file, which no matcher can tell apart from the prototype + // method; that static is deprecated and would just produce a redundant span + // so the collision is accepted rather than dropping the doc-method span. + { + channelName: 'model_remove', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'remove', kind: 'Auto' }, + }, + // NOTE: document `updateOne`/`deleteOne` (mongoose 8.21+) are deliberately + // NOT hooked here. The vendored OTel/IITM patcher wraps + // `Model.prototype.updateOne`/`deleteOne`, but those delegate to + // `Query.exec`, which the `query_exec` channel above already instruments + // (its `this.op` is the right operation). Verified by the `mongoose-v8` + // suite against a real mongoose 8.21+ under orchestrion. A dedicated hook + // is also not possible cleanly: `expressionName: 'updateOne'` in + // `lib/model.js` can't be told apart from the same-named `Model.updateOne` + // *static* (the common query-builder form), so hooking it would double-span + // every `Model.updateOne(...)` call. + // + // `Model.aggregate()` builds an `Aggregate` with no method to hook for + // context capture, so hook the static itself and stash the active span + // on the returned aggregate. `Sync`: it returns the aggregate. + { + channelName: 'model_aggregate', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'aggregate', kind: 'Sync' }, + }, + ...CONTEXT_CAPTURE_QUERY_METHODS.map(methodName => ({ + channelName: `ctx_${methodName}`, + module: { ...module, filePath: 'lib/query.js' }, + functionQuery: { expressionName: methodName, kind: 'Sync' as const }, + })), +] satisfies InstrumentationConfig[]; + +export const mongooseChannels = { + MONGOOSE_QUERY_EXEC: 'orchestrion:mongoose:query_exec', + MONGOOSE_AGGREGATE_EXEC: 'orchestrion:mongoose:aggregate_exec', + MONGOOSE_MODEL_SAVE: 'orchestrion:mongoose:model_save', + MONGOOSE_MODEL_INSERT_MANY: 'orchestrion:mongoose:model_insert_many', + MONGOOSE_MODEL_BULK_WRITE: 'orchestrion:mongoose:model_bulk_write', + MONGOOSE_MODEL_REMOVE: 'orchestrion:mongoose:model_remove', + MONGOOSE_MODEL_AGGREGATE: 'orchestrion:mongoose:model_aggregate', +} as const; + +/** + * Fully-qualified names of the context-capture channels, derived from the + * same method list the transform config uses so the two can't drift. The + * subscriber subscribes to all of them uniformly to stash the build-time + * parent span (see `mongooseChannels` for the span-creating channels). + */ +export const MONGOOSE_CONTEXT_CAPTURE_CHANNELS: string[] = CONTEXT_CAPTURE_QUERY_METHODS.map( + methodName => `orchestrion:mongoose:ctx_${methodName}`, +); diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 1933978787fc..ad15258d9b66 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -9,6 +9,7 @@ import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +import { mongooseChannelIntegration } from '../integrations/tracing-channel/mongoose'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { openaiChannelIntegration } from '../integrations/tracing-channel/openai'; import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; @@ -26,6 +27,7 @@ export { ioredisChannelIntegration, kafkajsChannelIntegration, lruMemoizerChannelIntegration, + mongooseChannelIntegration, mysqlChannelIntegration, openaiChannelIntegration, postgresChannelIntegration, @@ -59,6 +61,7 @@ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, postgresJsIntegration: postgresJsChannelIntegration, mysqlIntegration: mysqlChannelIntegration, + mongooseIntegration: mongooseChannelIntegration, lruMemoizerIntegration: lruMemoizerChannelIntegration, openaiIntegration: openaiChannelIntegration, anthropicIntegration: anthropicChannelIntegration,