diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/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/mongodb-v4/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs new file mode 100644 index 000000000000..70a62c9b9295 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +// maxPoolSize: 1 so the concurrent block below contends for the single +// pooled connection. the queued checkouts resolve from the pool's detached +// context, exercising the checkout context patch. +const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + + // Pool-contention: each op runs under its own span. If the pooled + // checkout loses the caller's context, a queued op's command span + // would parent to a sibling op instead of its own span. + await Promise.all( + ['a', 'b', 'c'].map(marker => + Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })), + ), + ); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts new file mode 100644 index 000000000000..28f5d5abb64b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts @@ -0,0 +1,76 @@ +import type { TransactionEvent } from '@sentry/core'; +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 mongodb 4 so the = 4.0 <6.4 callback-based command band and the +// pool-checkout context propagation are exercised against a real mongodb. +describe('MongoDB v4 auto-instrumentation', () => { + 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 origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + + // Checkout context propagation: each `op-*` span must be the + // parent of its own find command. A lost context would collapse + // them onto one parent (or the transaction). + const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id)); + expect(opIds.size).toBe(3); + const pooledFinds = spans.filter( + s => + s.origin === origin && + (s.data as Record)?.['db.operation'] === 'find' && + opIds.has(s.parent_span_id as string), + ); + expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '4.17.2' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-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/mongodb-v5/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs new file mode 100644 index 000000000000..9244d97eba47 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +// maxPoolSize: 1 so the concurrent block below contends for the single +// pooled connection. The queued checkouts resolve from the pool's detached +// context, exercising the checkout context patch. +const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + + // Pool-contention: each op runs under its own span. If the pooled + // checkout loses the caller's context, a queued op's command span + // would parent to a sibling op instead of its own span. + await Promise.all( + ['a', 'b', 'c'].map(marker => + Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })), + ), + ); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts new file mode 100644 index 000000000000..6b0c67965da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts @@ -0,0 +1,76 @@ +import type { TransactionEvent } from '@sentry/core'; +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 mongodb 5 so the >= 4.0 < 6.4 callback-based command band and the +// pool-checkout context propagation are exercised against a real mongodb. +describe('MongoDB v5 auto-instrumentation', () => { + 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 origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + + // Checkout context propagation: each `op-*` span must be the + // parent of its own find command. A lost context would collapse + // them onto one parent (or the transaction). + const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id)); + expect(opIds.size).toBe(3); + const pooledFinds = spans.filter( + s => + s.origin === origin && + (s.data as Record)?.['db.operation'] === 'find' && + opIds.has(s.parent_span_id as string), + ); + expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '5.9.2' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/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/mongodb-v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs new file mode 100644 index 000000000000..dad734bdbe5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +const client = new MongoClient(process.env.MONGO_URL || ''); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts new file mode 100644 index 000000000000..9ad2263efc0f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts @@ -0,0 +1,73 @@ +import type { TransactionEvent } from '@sentry/core'; +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 mongodb 6 so the >= 6.4 promise-based `Connection.prototype.command` +// band is exercised against a real mongodb. +describe('MongoDB v6 auto-instrumentation', () => { + 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 origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + // `db.statement` (scrubbed full command doc) and `db.connection_string` vary + // by driver version, so assert their presence rather than exact content; + // the operation-identifying attributes are exact. + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments modern `mongodb` (>= 6.4 promise command).', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + // No orphaned handshake/heartbeat spans! + // every command span has a parent. + const mongoSpans = spans.filter(s => s.origin === origin); + expect(mongoSpans.length).toBeGreaterThan(0); + for (const s of mongoSpans) { + expect(s.parent_span_id).toBeTruthy(); + } + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '6.21.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-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/mongodb-v7/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs new file mode 100644 index 000000000000..dad734bdbe5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +const client = new MongoClient(process.env.MONGO_URL || ''); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts new file mode 100644 index 000000000000..f6c47b9a4f67 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts @@ -0,0 +1,74 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, expect } from 'vitest'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 7 so the >= 6.4 promise-based `Connection.prototype.command` +// band is exercised against a real mongodb. mongodb 7 requires Node >= 20.19, so this suite is +// skipped on older Node (on Node 18 the driver throws `ReferenceError: crypto is not defined`). +conditionalTest({ min: 20 })('MongoDB v7 auto-instrumentation', () => { + 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 origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + // `db.statement` (scrubbed full command doc) and `db.connection_string` vary + // by driver version, so assert their presence rather than exact content; + // the operation-identifying attributes are exact. + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments modern `mongodb` (>= 6.4 promise command).', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + // No orphaned handshake/heartbeat spans! + // every command span has a parent. + const mongoSpans = spans.filter(s => s.origin === origin); + expect(mongoSpans.length).toBeGreaterThan(0); + for (const s of mongoSpans) { + expect(s.parent_span_id).toBeTruthy(); + } + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '7.5.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts index 68a32df6181b..5c88a8de18da 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts @@ -2,9 +2,11 @@ import type { TransactionEvent } from '@sentry/core'; import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; import { assertSentryTransaction } from '../../../utils/assertions'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('MongoDB auto-instrumentation', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -21,7 +23,7 @@ describe('MongoDB auto-instrumentation', () => { const SPAN_FIND_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -35,12 +37,12 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_INSERT_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -54,12 +56,12 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_ISMASTER_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -75,12 +77,12 @@ describe('MongoDB auto-instrumentation', () => { description: '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_UPDATE_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -94,13 +96,13 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); // A query the server rejects: same attributes as a successful find, but with an error status. const SPAN_FIND_ERROR_MATCHER = expect.objectContaining({ data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.operation': 'find', @@ -109,13 +111,13 @@ describe('MongoDB auto-instrumentation', () => { }), description: '{"$thisOperatorDoesNotExist":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, status: 'internal_error', }); const SPAN_ENDSESSIONS_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -128,7 +130,7 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts index dbacf38050e4..c5adf265bf52 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/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'; // mongoose >= 9.7.0 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes @@ -9,6 +9,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // query text, span relationships, and that the legacy IITM patcher does NOT also fire (no double // instrumentation). mongoose 9 requires Node >=20.19, so this suite is skipped on older Node. conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => { + const driverOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -104,7 +105,7 @@ conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => { // the underlying mongodb driver span must parent to the mongoose channel span, // proving the channel span is the active async context for the traced operation const driverChild = spans.find( - span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo', + span => span.parent_span_id === mongooseSave?.span_id && span.origin === driverOrigin, ); expect(driverChild).toBeDefined(); }, 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 b3a2b709c701..2a1bf262ab02 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -5,6 +5,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn describe('Mongoose experimental Test', () => { const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; + const driverOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -117,7 +118,7 @@ describe('Mongoose experimental Test', () => { 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', + span => span.parent_span_id === mongooseSave?.span_id && span.origin === driverOrigin, ); expect(driverChild).toBeDefined(); }, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 4a33a171fcaf..58611921021d 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -117,6 +117,7 @@ export { denoAmqplibIntegration } from './integrations/amqplib'; export { denoDataloaderIntegration } from './integrations/dataloader'; export { denoKnexIntegration } from './integrations/knex'; export { denoKoaIntegration } from './integrations/koa'; +export { denoMongoIntegration } from './integrations/mongo'; export { denoMongooseIntegration } from './integrations/mongoose'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; diff --git a/packages/deno/src/integrations/mongo.ts b/packages/deno/src/integrations/mongo.ts new file mode 100644 index 000000000000..15d6d1e9e028 --- /dev/null +++ b/packages/deno/src/integrations/mongo.ts @@ -0,0 +1,34 @@ +import { mongodbChannelIntegration } 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 = 'DenoMongo' as const; + +/** + * Create spans for `mongodb` queries under Deno. + * + * `mongodb` 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 mongodb's internal callback dispatch) before delegating. + */ +const _denoMongoIntegration = (() => { + const inner = mongodbChannelIntegration(); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoMongoIntegration = defineIntegration(_denoMongoIntegration) as () => Integration & { + name: 'DenoMongo'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index ce743f713b59..d9a75982b667 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -25,6 +25,7 @@ import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; import { denoAmqplibIntegration } from './integrations/amqplib'; import { denoKoaIntegration } from './integrations/koa'; +import { denoMongoIntegration } from './integrations/mongo'; import { denoMongooseIntegration } from './integrations/mongoose'; import { denoMysqlIntegration } from './integrations/mysql'; import { denoPostgresIntegration } from './integrations/postgres'; @@ -67,6 +68,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { ? [ denoAmqplibIntegration(), denoKoaIntegration(), + denoMongoIntegration(), denoMongooseIntegration(), denoMysqlIntegration(), denoPostgresIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index fe5046625075..7da66392eb30 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -117,6 +117,7 @@ snapshot[`captureException 1`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", @@ -197,6 +198,7 @@ snapshot[`captureMessage 1`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", @@ -284,6 +286,7 @@ snapshot[`captureMessage twice 1`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", @@ -378,6 +381,7 @@ snapshot[`captureMessage twice 2`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", diff --git a/packages/node/src/integrations/tracing/mongo/vendored/patches.ts b/packages/node/src/integrations/tracing/mongo/vendored/patches.ts index 1d1e7ef20cc7..ea29755c32d5 100644 --- a/packages/node/src/integrations/tracing/mongo/vendored/patches.ts +++ b/packages/node/src/integrations/tracing/mongo/vendored/patches.ts @@ -18,9 +18,8 @@ import type { V4ConnectionPool, WireProtocolInternal, } from './internal-types'; -import { MongodbCommandType } from './internal-types'; import { - getCommandType, + getV3CommandOperation, getV3SpanAttributes, getV4SpanAttributes, patchEnd, @@ -82,8 +81,7 @@ export function getV3PatchCommand() { } } - const commandType = getCommandType(cmd); - const operationName = commandType === MongodbCommandType.UNKNOWN ? undefined : commandType; + const operationName = getV3CommandOperation(cmd as unknown as Record); const span = startMongoSpan(getV3SpanAttributes(ns, server, cmd, operationName)); const patchedCallback = patchEnd(span, resultHandler); diff --git a/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts b/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts deleted file mode 100644 index 1a4c1680e84f..000000000000 --- a/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongodb - * - Upstream version: @opentelemetry/instrumentation-mongodb@0.71.0 - */ - -/* - * This file contains a copy of unstable semantic convention definitions - * used by this package. - * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv - */ - -/** - * Deprecated, use `server.address`, `server.port` attributes instead. - * - * @deprecated Replaced by `server.address` and `server.port`. - */ -export const ATTR_DB_CONNECTION_STRING = 'db.connection_string' as const; - -/** - * Deprecated, use `db.collection.name` instead. - * - * @deprecated Replaced by `db.collection.name`. - */ -export const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection' as const; - -/** - * Enum value "mongodb" for attribute `db.system`. - */ -export const DB_SYSTEM_VALUE_MONGODB = 'mongodb' as const; diff --git a/packages/node/src/integrations/tracing/mongo/vendored/utils.ts b/packages/node/src/integrations/tracing/mongo/vendored/utils.ts index 6c1430c0ce16..22876deae063 100644 --- a/packages/node/src/integrations/tracing/mongo/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/mongo/vendored/utils.ts @@ -6,205 +6,33 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongodb * - Upstream version: @opentelemetry/instrumentation-mongodb@0.71.0 * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs + * - The db/net attribute extraction, `db.statement` scrubbing and span + * builder are shared with the orchestrion mongodb integration in + * `@sentry/server-utils` so the two emit an identical span shape. + * Only the OTel-specific callback/context helpers below remain here. */ import type { Span, SpanAttributes } from '@sentry/core'; +import { getActiveSpan, SPAN_STATUS_ERROR, withActiveSpan } from '@sentry/core'; import { - getActiveSpan, - isObjectLike, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_KIND, - SPAN_STATUS_ERROR, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; -import { - DB_NAME, - DB_OPERATION, - DB_STATEMENT, - DB_SYSTEM, - NET_PEER_NAME, - NET_PEER_PORT, -} from '@sentry/conventions/attributes'; -import { ATTR_DB_CONNECTION_STRING, ATTR_DB_MONGODB_COLLECTION, DB_SYSTEM_VALUE_MONGODB } from './semconv'; -import type { MongodbNamespace, MongoInternalCommand, MongoInternalTopology } from './internal-types'; -import { MongodbCommandType } from './internal-types'; + getV3CommandOperation, + getV3SpanAttributes as sharedGetV3SpanAttributes, + getV4SpanAttributes as sharedGetV4SpanAttributes, + startMongoSpan, +} from '@sentry/server-utils'; const ORIGIN = 'auto.db.otel.mongo'; -/** - * Replaces values in the command object with '?', hiding PII and helping grouping. - */ -function serializeDbStatement(commandObj: Record): string { - return JSON.stringify(scrubStatement(commandObj)); -} - -function scrubStatement(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(element => scrubStatement(element)); - } - - if (isCommandObj(value)) { - const initial: Record = {}; - return Object.entries(value) - .map(([key, element]) => [key, scrubStatement(element)]) - .reduce((prev, current) => { - if (isCommandEntry(current)) { - prev[current[0]] = current[1]; - } - return prev; - }, initial); - } - - // A value like string or number, possibly contains PII, scrub it - return '?'; -} - -function isCommandObj(value: Record | unknown): value is Record { - return isObjectLike(value) && !isBuffer(value); -} - -function isBuffer(value: unknown): boolean { - return typeof Buffer !== 'undefined' && Buffer.isBuffer(value); -} - -function isCommandEntry(value: [string, unknown] | unknown): value is [string, unknown] { - return Array.isArray(value); -} - -/** - * Get the mongodb command type from the object. - */ -export function getCommandType(command: MongoInternalCommand): MongodbCommandType { - if (command.createIndexes !== undefined) { - return MongodbCommandType.CREATE_INDEXES; - } else if (command.findandmodify !== undefined) { - return MongodbCommandType.FIND_AND_MODIFY; - } else if (command.ismaster !== undefined) { - return MongodbCommandType.IS_MASTER; - } else if (command.count !== undefined) { - return MongodbCommandType.COUNT; - } else if (command.aggregate !== undefined) { - return MongodbCommandType.AGGREGATE; - } else { - return MongodbCommandType.UNKNOWN; - } -} - -/** - * Determine a span's attributes by fetching related metadata from the v4 connection context. - */ -export function getV4SpanAttributes( - connectionCtx: any, - ns: MongodbNamespace, - command?: any, - operation?: string, -): SpanAttributes { - let host, port: undefined | string; - if (connectionCtx) { - const hostParts = typeof connectionCtx.address === 'string' ? connectionCtx.address.split(':') : ''; - if (hostParts.length === 2) { - host = hostParts[0]; - port = hostParts[1]; - } - } - let commandObj: Record; - if (command?.documents && command.documents[0]) { - commandObj = command.documents[0]; - } else if (command?.cursors) { - commandObj = command.cursors; - } else { - commandObj = command; - } - - return getSpanAttributes(ns.db, ns.collection, host, port, commandObj, operation); -} - -/** - * Determine a span's attributes by fetching related metadata from the v3 topology. - */ -export function getV3SpanAttributes( - ns: string, - topology: MongoInternalTopology, - command?: MongoInternalCommand, - operation?: string | undefined, -): SpanAttributes { - let host: undefined | string; - let port: undefined | string; - if (topology?.s) { - host = topology.s.options?.host ?? topology.s.host; - port = (topology.s.options?.port ?? topology.s.port)?.toString(); - if (host == null || port == null) { - const address = topology.description?.address; - if (address) { - const addressSegments = address.split(':'); - host = addressSegments[0]; - port = addressSegments[1]; - } - } - } - - // The namespace is a combination of the database name and the name of the - // collection or index, like so: [database-name].[collection-or-index-name]. - // It could be a string or an instance of MongoDBNamespace, as such we - // always coerce to a string to extract db and collection. - const [dbName, dbCollection] = ns.toString().split('.'); - const commandObj = command?.query ?? command?.q ?? command; - - return getSpanAttributes(dbName, dbCollection, host, port, commandObj, operation); -} - -function getSpanAttributes( - dbName?: string, - dbCollection?: string, - host?: undefined | string, - port?: undefined | string, - commandObj?: any, - operation?: string | undefined, -): SpanAttributes { - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - // eslint-disable-next-line typescript/no-deprecated - [DB_SYSTEM]: DB_SYSTEM_VALUE_MONGODB, - // eslint-disable-next-line typescript/no-deprecated - [DB_NAME]: dbName, - // eslint-disable-next-line typescript/no-deprecated - [ATTR_DB_MONGODB_COLLECTION]: dbCollection, - // eslint-disable-next-line typescript/no-deprecated - [DB_OPERATION]: operation, - // eslint-disable-next-line typescript/no-deprecated - [ATTR_DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`, - }; - - if (host && port) { - // eslint-disable-next-line typescript/no-deprecated - attributes[NET_PEER_NAME] = host; - const portNumber = parseInt(port, 10); - if (!isNaN(portNumber)) { - // eslint-disable-next-line typescript/no-deprecated - attributes[NET_PEER_PORT] = portNumber; - } - } - - if (commandObj) { - try { - // eslint-disable-next-line typescript/no-deprecated - attributes[DB_STATEMENT] = serializeDbStatement(commandObj); - } catch { - // ignore serialization errors — the statement is best-effort metadata - } - } +export { getV3CommandOperation, startMongoSpan }; - return attributes; +/** Determine a span's attributes from the v4 connection context (OTel origin). */ +export function getV4SpanAttributes(connectionCtx: any, ns: any, command?: any, operation?: string): SpanAttributes { + return sharedGetV4SpanAttributes(connectionCtx, ns, command, operation, ORIGIN); } -export function startMongoSpan(attributes: SpanAttributes): Span { - return startInactiveSpan({ - // eslint-disable-next-line typescript/no-deprecated - name: `mongodb.${attributes[DB_OPERATION] || 'command'}`, - kind: SPAN_KIND.CLIENT, - attributes, - }); +/** Determine a span's attributes from the v3 topology (OTel origin). */ +export function getV3SpanAttributes(ns: string, topology: any, command?: any, operation?: string): SpanAttributes { + return sharedGetV3SpanAttributes(ns, topology, command, operation, ORIGIN); } /** diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 2e7cd578ee94..7030a0cca557 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -7,6 +7,13 @@ export { graphqlIntegration } from './graphql'; export { mongooseIntegration, startMongooseLegacySpan } from './mongoose'; export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose'; +export { + getV3CommandOperation, + getV3SpanAttributes, + getV4SpanAttributes, + startMongoSpan, +} from './mongodb/mongodb-span'; +export type { MongodbNamespace, MongoV3Topology } from './mongodb/mongodb-span'; export { mysql2Integration } from './mysql2'; export { instrumentPrisma, prismaIntegration } from './prisma'; export type { PrismaInstrumentationConfig, PrismaOptions } from './prisma'; diff --git a/packages/server-utils/src/integrations/tracing-channel/mongodb.ts b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts new file mode 100644 index 000000000000..85899d910792 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts @@ -0,0 +1,183 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import type { MongodbNamespace, MongoV3Topology } from '../../mongodb/mongodb-span'; +import { + getV3CommandOperation, + getV3SpanAttributes, + getV4SpanAttributes, + startMongoSpan, +} from '../../mongodb/mongodb-span'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +const INTEGRATION_NAME = 'Mongo' as const; + +const ORIGIN = 'auto.db.orchestrion.mongo'; + +/** + * what orchestrion's transform attaches to a channel context: + * `self` is the `this`, plus args. + */ +interface MongoChannelContext { + self?: { address?: string }; + arguments?: unknown[]; +} + +// Details extracted from a v3 wireprotocol call's arguments to build its span. +interface V3CallInfo { + topology: MongoV3Topology | undefined; + ns: string; + command: Record | undefined; + operation: string | undefined; +} + +// Command doc first-keys whose span the `v3_command` channel must suppress. +// The `insert`/`update`/`remove`/`query`/`getMore` functions call the shared +// `command` function internally. Orchestrion transforms the `command` *source* +// (unlike the vendored OTel patch, which wraps the module's exported reference +// and misses internal calls), so `v3_command` would double-span the ones that +// already have their own dedicated channel (`v3_insert`/`update`/`remove`/ +// `query`/`get_more`) without this guard. +// +// `killCursors` has no dedicated channel, but is suppressed too for OTel +// parity: OTel wraps `wireprotocol/index.js`'s `command` property, which never +// intercepts `kill_cursors.js`'s direct `require('./command')` call — so OTel +// emits no killCursors span, and neither should we. +const V3_DEDICATED_COMMANDS = new Set(['insert', 'update', 'delete', 'find', 'getMore', 'killCursors']); + +const _mongodbChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + subscribeV4Command(); + subscribeV4Checkout(); + subscribeV3Wireprotocol(); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * `Connection.prototype.command` (mongodb >=4.0) one span per command. + * Handles both the >=6.4 promise form and the >=4.0 <6.4 callback form + * (both publish to this channel). + */ +function subscribeV4Command(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_COMMAND), + data => { + const args = data.arguments ?? []; + const ns = args[0] as MongodbNamespace | undefined; + const cmd = args[1] as Record | undefined; + // Skip handshake/heartbeat commands (matches otel). + if (!ns || !cmd || typeof cmd !== 'object' || cmd.ismaster || cmd.hello) { + return undefined; + } + const operation = Object.keys(cmd)[0]; + return startMongoSpan(getV4SpanAttributes(data.self, ns, cmd, operation, ORIGIN)); + }, + // Matches otel's `shouldSkipInstrumentation`: only trace when there is + // an active parent span, to avoid emitting orphaned mongodb spans. + { requiresParentSpan: true }, + ); +} + +/** + * `ConnectionPool.prototype.checkOut` (mongodb 4.0 - 6.3, callback form). + * Creates no span (`getSpan` returns `undefined`) but the binding re-runs + * the checkout callback under the caller's captured context, so the pooled + * `command()` invoked inside it re-inherits the active span. + */ +function subscribeV4Checkout(): void { + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_CHECKOUT), () => undefined); +} + +/** + * mongodb >=3.3 <4 had no unified `command`; each operation is a separate + * `lib/core/wireprotocol` function with its own argument layout, so each + * channel extracts topology/namespace/command/op differently before building + * the span. + */ +function subscribeV3Wireprotocol(): void { + for (const operation of ['insert', 'update', 'remove'] as const) { + const channel = + operation === 'insert' + ? CHANNELS.MONGODB_V3_INSERT + : operation === 'update' + ? CHANNELS.MONGODB_V3_UPDATE + : CHANNELS.MONGODB_V3_REMOVE; + bindV3(channel, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: (args[2] as Record[] | undefined)?.[0], + operation, + })); + } + + // `command`(server, ns, cmd, options, callback) operation derived from the + // command doc. Skips commands that have a dedicated channel. See set above. + bindV3(CHANNELS.MONGODB_V3_COMMAND, args => { + const command = args[2] as Record | undefined; + const type = command ? Object.keys(command)[0] : undefined; + if (type && V3_DEDICATED_COMMANDS.has(type)) { + return undefined; + } + return { + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command, + operation: command ? getV3CommandOperation(command) : undefined, + }; + }); + + // `query`(server, ns, cmd, cursorState, options, callback). a find. + bindV3(CHANNELS.MONGODB_V3_QUERY, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: args[2] as Record | undefined, + operation: 'find', + })); + + // `getMore`(server, ns, cursorState, batchSize, options, callback) + // command doc is `cursorState.cmd`. + bindV3(CHANNELS.MONGODB_V3_GET_MORE, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: (args[2] as { cmd?: Record } | undefined)?.cmd, + operation: 'getMore', + })); +} + +function bindV3(channelName: string, extract: (args: unknown[]) => V3CallInfo | undefined): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + const args = data.arguments; + if (!args) { + return undefined; + } + const info = extract(args); + if (!info || typeof info.ns !== 'string') { + return undefined; + } + return startMongoSpan(getV3SpanAttributes(info.ns, info.topology, info.command, info.operation, ORIGIN)); + }, + { requiresParentSpan: true }, + ); +} + +/** + * EXPERIMENTAL: orchestrion-driven mongodb integration. + * + * Reproduces the vendored `@opentelemetry/instrumentation-mongodb` span shape + * (legacy db/net semantic conventions, `mongodb.` names, scrubbed + * `db.statement`) via the `orchestrion:mongodb:*` diagnostics_channels + * injected by the orchestrion code transform. + */ +export const mongodbChannelIntegration = defineIntegration(_mongodbChannelIntegration); diff --git a/packages/server-utils/src/mongodb/mongodb-span.ts b/packages/server-utils/src/mongodb/mongodb-span.ts new file mode 100644 index 000000000000..3904274a99f3 --- /dev/null +++ b/packages/server-utils/src/mongodb/mongodb-span.ts @@ -0,0 +1,229 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core'; + +// OTel "OLD" db/net semantic-conventions, reproduced from the vendored +// `@opentelemetry/instrumentation-mongodb` span shape so the orchestrion +// spans match the OTel ones byte-for-byte. Inlined as literals to avoid +// importing the deprecated convention constants. +const ATTR_DB_SYSTEM = 'db.system'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_OPERATION = 'db.operation'; +const ATTR_DB_STATEMENT = 'db.statement'; +const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection'; +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const DB_SYSTEM_VALUE_MONGODB = 'mongodb'; + +/** + * The db/collection namespace mongodb (v4+) passes to `Connection.command` + */ +export interface MongodbNamespace { + db: string; + collection?: string; +} + +interface V4Command { + documents?: unknown[]; + cursors?: unknown; + [key: string]: unknown; +} + +/** + * Replaces every leaf value in the command object with '?', keeping keys + * and Mongo operators. hides PII and improves grouping. Reproduced from + * the OTel instrumentation. + */ +export function serializeDbStatement(commandObj: Record): string { + return JSON.stringify(scrubStatement(commandObj)); +} + +function scrubStatement(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(element => scrubStatement(element)); + } + + if (isCommandObj(value)) { + const initial: Record = {}; + return Object.entries(value) + .map(([key, element]) => [key, scrubStatement(element)]) + .reduce((prev, current) => { + if (isCommandEntry(current)) { + prev[current[0]] = current[1]; + } + return prev; + }, initial); + } + + // A value like string or number, possibly contains PII, scrub it. + return '?'; +} + +function isCommandObj(value: unknown): value is Record { + return isObjectLike(value) && !isBuffer(value); +} + +function isBuffer(value: unknown): boolean { + return typeof Buffer !== 'undefined' && Buffer.isBuffer(value); +} + +function isCommandEntry(value: unknown): value is [string, unknown] { + return Array.isArray(value); +} + +/** + * Build span attributes for a mongodb v4+ command from the connection + * context, namespace and command. + */ +export function getV4SpanAttributes( + connectionCtx: { address?: string } | undefined, + ns: MongodbNamespace, + command: V4Command | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + let host: string | undefined; + let port: string | undefined; + if (connectionCtx) { + const hostParts = typeof connectionCtx.address === 'string' ? connectionCtx.address.split(':') : ''; + if (hostParts.length === 2) { + host = hostParts[0]; + port = hostParts[1]; + } + } + + let commandObj: Record | undefined; + if (command?.documents?.[0]) { + commandObj = command.documents[0] as Record; + } else if (command?.cursors) { + commandObj = command.cursors as Record; + } else { + commandObj = command; + } + + return getSpanAttributes(ns.db, ns.collection, host, port, commandObj, operation, origin); +} + +/** + * Shared attribute builder used by both the v3 topology path and the v4+ + * connection path. + */ +export function getSpanAttributes( + dbName: string | undefined, + dbCollection: string | undefined, + host: string | undefined, + port: string | undefined, + commandObj: Record | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_MONGODB, + [ATTR_DB_NAME]: dbName, + [ATTR_DB_MONGODB_COLLECTION]: dbCollection, + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`, + }; + + if (host && port) { + attributes[ATTR_NET_PEER_NAME] = host; + const portNumber = parseInt(port, 10); + if (!isNaN(portNumber)) { + attributes[ATTR_NET_PEER_PORT] = portNumber; + } + } + + if (commandObj) { + try { + attributes[ATTR_DB_STATEMENT] = serializeDbStatement(commandObj); + } catch { + // ignore serialization errors — the statement is best-effort metadata + } + } + + return attributes; +} + +/** The v3 driver topology, from which host/port are read (mongodb 3.x). */ +export interface MongoV3Topology { + s?: { + options?: { host?: string; port?: number }; + host?: string; + port?: number; + }; + description?: { address?: string }; +} + +/** + * Determine the operation name for a mongodb v3 `command` from the command + * document, mirroring the vendored instrumentation. Returns `undefined` for + * commands it doesn't classify (e.g. `endSessions`). + */ +export function getV3CommandOperation(command: Record): string | undefined { + if (command.createIndexes !== undefined) { + return 'createIndexes'; + } else if (command.findandmodify !== undefined) { + return 'findAndModify'; + } else if (command.ismaster !== undefined) { + return 'isMaster'; + } else if (command.count !== undefined) { + return 'count'; + } else if (command.aggregate !== undefined) { + return 'aggregate'; + } + return undefined; +} + +/** + * Build span attributes for a mongodb v3 operation from the topology, + * namespace string and command. + */ +export function getV3SpanAttributes( + ns: string, + topology: MongoV3Topology | undefined, + command: Record | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + let host: string | undefined; + let port: string | undefined; + if (topology?.s) { + host = topology.s.options?.host ?? topology.s.host; + port = (topology.s.options?.port ?? topology.s.port)?.toString(); + if (host == null || port == null) { + const address = topology.description?.address; + if (address) { + const segments = address.split(':'); + host = segments[0]; + port = segments[1]; + } + } + } + + // The namespace is `[db].[collection-or-index]`; coerce to string + // (might be a MongoDBNamespace) + const [dbName, dbCollection] = ns.toString().split('.'); + const commandObj = + (command?.query as Record | undefined) ?? + (command?.q as Record | undefined) ?? + command; + + return getSpanAttributes(dbName, dbCollection, host, port, commandObj, operation, origin); +} + +/** + * Start a mongodb client span with the legacy (pre-stable) db/net semantic + * conventions. + * + * `op: 'db'` is set explicitly rather than relying on `inferDbSpanData`, + * to support platforms that lack it (ie, Deno). + */ +export function startMongoSpan(attributes: SpanAttributes): Span { + return startInactiveSpan({ + name: `mongodb.${attributes[ATTR_DB_OPERATION] || 'command'}`, + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes, + }); +} diff --git a/packages/server-utils/src/orchestrion/config/mongodb.ts b/packages/server-utils/src/orchestrion/config/mongodb.ts index 04c623f0ba30..ee44b79b0bba 100644 --- a/packages/server-utils/src/orchestrion/config/mongodb.ts +++ b/packages/server-utils/src/orchestrion/config/mongodb.ts @@ -1,6 +1,72 @@ import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `mongodb` orchestrion integration (ports `@opentelemetry/instrumentation-mongodb`). -export const mongodbConfig: InstrumentationConfig[] = []; +// The mongodb driver's command architecture changed across majors, mirrored in the vendored OTel +// instrumentation's version bands: +// - >= 6.4: promise-based `Connection.prototype.command` +// - >= 4.0 < 6.4: callback-based `Connection.prototype.command` + `ConnectionPool.checkOut` +// (the pool runs the checkout callback in a detached context, so the channel re-propagates the +// caller's async context to it — see the subscriber) +// - >= 3.3 < 4: the `lib/core/wireprotocol` module functions +const module = { name: 'mongodb' } as const; -export const mongodbChannels = {} as const; +export const mongodbConfig = [ + // Band 1: mongodb >= 6.4 — promise-based command. + // `methodName`-only (no `className`): the code-transformer's `className` matcher throws on classes + // containing ES2022 `static {}` blocks (mongodb 7.x's `Connection`/`ConnectionPool` have them — see + // `transformer-bug.md`), and `methodName` alone matches exactly the base method across all versions. + { + channelName: 'command', + module: { ...module, versionRange: '>=6.4.0 <8', filePath: 'lib/cmap/connection.js' }, + functionQuery: { methodName: 'command', kind: 'Async' }, + }, + // Band 2: mongodb >= 4.0 < 6.4 — callback-based command (same `command` channel, different kind). + { + channelName: 'command', + module: { ...module, versionRange: '>=4.0.0 <6.4', filePath: 'lib/cmap/connection.js' }, + functionQuery: { methodName: 'command', kind: 'Callback' }, + }, + // Band 2: the pool runs the checkout callback in a detached async context, so the operation's + // `command()` (invoked inside it) loses the caller's active span. Hooking `checkOut` re-propagates + // that context to the callback (the subscriber creates no span — see `getSpan` returning undefined). + // Only needed < 6.4; from 6.4 `checkOut` is promise-based and the context survives natively. + { + channelName: 'checkout', + module: { ...module, versionRange: '>=4.0.0 <6.4', filePath: 'lib/cmap/connection_pool.js' }, + functionQuery: { methodName: 'checkOut', kind: 'Callback' }, + }, + // Band 3: mongodb >= 3.3 < 4 — the driver had no unified `command`; each operation is a separate + // `lib/core/wireprotocol` function, all callback-style. `insert`/`update`/`remove` are named + // function expressions in the `index.js` `module.exports` object (matched by `expressionName`); + // `command`/`query`/`getMore` are single-function modules (matched by `functionName`). + ...(['insert', 'update', 'remove'] as const).map(op => ({ + channelName: `v3_${op}`, + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/index.js' }, + functionQuery: { expressionName: op, kind: 'Callback' as const }, + })), + { + channelName: 'v3_command', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/command.js' }, + functionQuery: { functionName: 'command', kind: 'Callback' }, + }, + { + channelName: 'v3_query', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/query.js' }, + functionQuery: { functionName: 'query', kind: 'Callback' }, + }, + { + channelName: 'v3_get_more', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/get_more.js' }, + functionQuery: { functionName: 'getMore', kind: 'Callback' }, + }, +] satisfies InstrumentationConfig[]; + +export const mongodbChannels = { + MONGODB_COMMAND: 'orchestrion:mongodb:command', + MONGODB_CHECKOUT: 'orchestrion:mongodb:checkout', + MONGODB_V3_INSERT: 'orchestrion:mongodb:v3_insert', + MONGODB_V3_UPDATE: 'orchestrion:mongodb:v3_update', + MONGODB_V3_REMOVE: 'orchestrion:mongodb:v3_remove', + MONGODB_V3_COMMAND: 'orchestrion:mongodb:v3_command', + MONGODB_V3_QUERY: 'orchestrion:mongodb:v3_query', + MONGODB_V3_GET_MORE: 'orchestrion:mongodb:v3_get_more', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index eac0c5956915..d82fa7c8ccbd 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -16,6 +16,7 @@ import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; import { langChainChannelIntegration } from '../integrations/tracing-channel/langchain'; import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +import { mongodbChannelIntegration } from '../integrations/tracing-channel/mongodb'; import { mongooseChannelIntegration } from '../integrations/tracing-channel/mongoose'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2'; @@ -46,6 +47,7 @@ export { langChainChannelIntegration, langGraphChannelIntegration, lruMemoizerChannelIntegration, + mongodbChannelIntegration, mongooseChannelIntegration, mysqlChannelIntegration, mysql2ChannelIntegration, @@ -92,6 +94,7 @@ export type * from '../integrations/tracing-channel/graphql/graphql-types'; export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, postgresJsIntegration: postgresJsChannelIntegration, + mongoIntegration: mongodbChannelIntegration, mysqlIntegration: mysqlChannelIntegration, mysql2Integration: mysql2ChannelIntegration, genericPoolIntegration: genericPoolChannelIntegration,