From 3d1321d999652e24617ed696b8e92a381a266036 Mon Sep 17 00:00:00 2001 From: isaacs Date: Sun, 12 Jul 2026 17:12:07 -0700 Subject: [PATCH 1/3] feat(mongoose): implement orchestrion mongoose integration Port the OTel mongoose intstrumentation to Orchestrion. Add Deno integration, and node integration tests for mongoose versions 5, 6, 7, 8, and 9. Native diagnostics channel used on Mongoose versions supporting them (ie, 9.7+). Fix: JS-2412 Fix: #20761 --- .../suites/tracing/mongoose-v5/instrument.mjs | 9 + .../suites/tracing/mongoose-v5/scenario.mjs | 36 +++ .../suites/tracing/mongoose-v5/test.ts | 59 +++++ .../suites/tracing/mongoose-v7/test.ts | 4 +- .../suites/tracing/mongoose-v8/test.ts | 8 +- .../suites/tracing/mongoose-v9/test.ts | 5 +- .../suites/tracing/mongoose/test.ts | 16 +- packages/deno/src/index.ts | 1 + packages/deno/src/integrations/mongoose.ts | 34 +++ packages/deno/src/sdk.ts | 3 +- .../deno/test/__snapshots__/mod.test.ts.snap | 4 + .../integrations/tracing-channel/mongoose.ts | 246 ++++++++++++++++++ .../src/orchestrion/config/mongoose.ts | 126 ++++++++- .../server-utils/src/orchestrion/index.ts | 3 + 14 files changed, 537 insertions(+), 17 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts create mode 100644 packages/deno/src/integrations/mongoose.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/mongoose.ts 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/server-utils/src/integrations/tracing-channel/mongoose.ts b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts new file mode 100644 index 000000000000..6c4ca3ee27e6 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mongoose.ts @@ -0,0 +1,246 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { subscribeMongooseDiagnosticChannels } from '../../mongoose/mongoose-dc-subscriber'; +import { CHANNELS } from '../../orchestrion/channels'; +import { MONGOOSE_CONTEXT_CAPTURE_CHANNELS } from '../../orchestrion/config/mongoose'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { SentryTracingChannel } from '../../tracing-channel'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +const INTEGRATION_NAME = 'Mongoose' as const; + +// Origin distinguishes the orchestrion path from the OTel/IITM path +// (`auto.db.otel.mongoose`) and the native diagnostics_channel path +// (`auto.db.mongoose.diagnostic_channel`). +const ORIGIN = 'auto.db.orchestrion.mongoose'; + +// OTel "OLD" db/net semantic-conventions, reproduced from the vendored +// `@opentelemetry/instrumentation-mongoose` span shape so the orchestrion +// spans match the OTel ones. 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'; + +interface MongooseCollection { + name?: string; + conn?: { name?: string; user?: string; host?: string; port?: number }; +} + +interface MongooseQuery { + mongooseCollection?: MongooseCollection; + model?: { modelName?: string }; + op?: string; +} + +interface MongooseAggregate { + _model?: { collection?: MongooseCollection; modelName?: string }; +} + +interface MongooseModelStatic { + collection?: MongooseCollection; + modelName?: string; +} + +interface MongooseDocument { + constructor: { collection?: MongooseCollection; modelName?: string }; +} + +/** + * The shape orchestrion's transform attaches to the tracing-channel context + * object. `self` is the `this` of the traced method + */ +interface MongooseChannelContext { + self?: object; + arguments?: unknown[]; + result?: unknown; + error?: unknown; + moduleVersion?: string; +} + +// The active span captured when a query/aggregate was *built*, keyed by the +// query/aggregate object. +// +// Used as the parent for the span created at `exec()` so a query parents to +// where it was built, not where it happens to be awaited. A WeakMap avoids +// mutating mongoose's own objects. +const STORED_PARENT_SPAN = new WeakMap(); + +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 startMongooseSpan( + 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 startMongooseSpan(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 startMongooseSpan(ctor.collection, ctor.modelName, 'save'); + }); + + // `doc.remove()` (mongoose 5/6). + bindExecSpan(CHANNELS.MONGOOSE_MODEL_REMOVE, self => { + const ctor = (self as MongooseDocument).constructor; + return startMongooseSpan(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 startMongooseSpan(model.collection, model.modelName, 'insertMany'); + }); + bindExecSpan(CHANNELS.MONGOOSE_MODEL_BULK_WRITE, self => { + const model = self as MongooseModelStatic; + return startMongooseSpan(model.collection, model.modelName, 'bulkWrite'); + }); +} + +// `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); + } +} + +function startMongooseSpan( + collection: MongooseCollection | undefined, + modelName: string | undefined, + operation: string, + parentSpan?: Span, +): Span { + const attributes: SpanAttributes = { + ...getAttributesFromCollection(collection), + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_SYSTEM]: 'mongoose', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }; + + return startInactiveSpan({ + name: `mongoose.${modelName}.${operation}`, + kind: SPAN_KIND.CLIENT, + attributes, + parentSpan, + }); +} + +function getAttributesFromCollection(collection: MongooseCollection | undefined): 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, + }; +} + +/** + * 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/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, From 2d38636af0b5578072fe6d1bd706f177fcd302f6 Mon Sep 17 00:00:00 2001 From: isaacs Date: Sun, 12 Jul 2026 17:34:11 -0700 Subject: [PATCH 2/3] ref(mongoose): extract shared utils and reuse in both impls --- .../tracing/mongoose/vendored/mongoose.ts | 70 +++++++------- .../tracing/mongoose/vendored/semconv.ts | 17 ---- .../tracing/mongoose/vendored/utils.ts | 20 +--- packages/server-utils/src/index.ts | 3 +- .../integrations/tracing-channel/mongoose.ts | 91 +++++-------------- packages/server-utils/src/mongoose/index.ts | 3 + .../src/mongoose/mongoose-legacy-span.ts | 62 +++++++++++++ 7 files changed, 128 insertions(+), 138 deletions(-) delete mode 100644 packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts create mode 100644 packages/server-utils/src/mongoose/mongoose-legacy-span.ts 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 { const query = self as MongooseQuery; - return startMongooseSpan( + return startSpan( query.mongooseCollection, query.model?.modelName, query.op ?? 'exec', @@ -146,32 +123,41 @@ function subscribeOrchestrionMongooseChannels(): void { // Aggregation execution. bindExecSpan(CHANNELS.MONGOOSE_AGGREGATE_EXEC, self => { const model = (self as MongooseAggregate)._model; - return startMongooseSpan(model?.collection, model?.modelName, 'aggregate', STORED_PARENT_SPAN.get(self)); + 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 startMongooseSpan(ctor.collection, ctor.modelName, 'save'); + 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 startMongooseSpan(ctor.collection, ctor.modelName, 'remove'); + 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 startMongooseSpan(model.collection, model.modelName, 'insertMany'); + return startSpan(model.collection, model.modelName, 'insertMany'); }); bindExecSpan(CHANNELS.MONGOOSE_MODEL_BULK_WRITE, self => { const model = self as MongooseModelStatic; - return startMongooseSpan(model.collection, model.modelName, 'bulkWrite'); + 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. @@ -201,37 +187,6 @@ function stashParentSpan(self: object | undefined): void { } } -function startMongooseSpan( - collection: MongooseCollection | undefined, - modelName: string | undefined, - operation: string, - parentSpan?: Span, -): Span { - const attributes: SpanAttributes = { - ...getAttributesFromCollection(collection), - [ATTR_DB_OPERATION]: operation, - [ATTR_DB_SYSTEM]: 'mongoose', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - }; - - return startInactiveSpan({ - name: `mongoose.${modelName}.${operation}`, - kind: SPAN_KIND.CLIENT, - attributes, - parentSpan, - }); -} - -function getAttributesFromCollection(collection: MongooseCollection | undefined): 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, - }; -} - /** * EXPERIMENTAL: orchestrion-driven mongoose integration. * 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..62a516f8ba2a --- /dev/null +++ b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts @@ -0,0 +1,62 @@ +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}`, + kind: SPAN_KIND.CLIENT, + attributes, + parentSpan, + }); +} From 44073c930b6a78ab7ed34e89d26df2f45c78bfbd Mon Sep 17 00:00:00 2001 From: isaacs Date: Sun, 12 Jul 2026 18:11:28 -0700 Subject: [PATCH 3/3] fix(mongoose): set 'op' explicitly, for platforms lacking inferSpanData --- packages/server-utils/src/mongoose/mongoose-legacy-span.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server-utils/src/mongoose/mongoose-legacy-span.ts b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts index 62a516f8ba2a..301b9be210d7 100644 --- a/packages/server-utils/src/mongoose/mongoose-legacy-span.ts +++ b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts @@ -55,6 +55,8 @@ export function startMongooseLegacySpan({ return startInactiveSpan({ name: `mongoose.${modelName}.${operation}`, + // Set this explicitly, for platforms lacking `inferDbSpanData` + op: 'db', kind: SPAN_KIND.CLIENT, attributes, parentSpan,