Skip to content

Commit 2ca59a3

Browse files
committed
feat(mongodb): implement orchestrion mongodb integration
Fix: JS-2411 Fix: #20760
1 parent 9e04d98 commit 2ca59a3

25 files changed

Lines changed: 1085 additions & 250 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as Sentry from '@sentry/node';
2+
import mongodb from 'mongodb';
3+
4+
const { MongoClient } = mongodb;
5+
6+
// maxPoolSize: 1 so the concurrent block below contends for the single
7+
// pooled connection. the queued checkouts resolve from the pool's detached
8+
// context, exercising the checkout context patch.
9+
const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 });
10+
11+
async function run() {
12+
await Sentry.startSpan(
13+
{
14+
name: 'Test Transaction',
15+
op: 'transaction',
16+
},
17+
async () => {
18+
try {
19+
await client.connect();
20+
21+
const collection = client.db('admin').collection('movies');
22+
23+
await collection.insertOne({ title: 'Rick and Morty' });
24+
await collection.findOne({ title: 'Back to the Future' });
25+
await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } });
26+
await collection.find({ title: 'South Park' }).toArray();
27+
28+
// A query the server rejects, to exercise the error-status span path.
29+
await collection
30+
.find({ $thisOperatorDoesNotExist: 1 })
31+
.toArray()
32+
.catch(() => {});
33+
34+
// Pool-contention: each op runs under its own span. If the pooled
35+
// checkout loses the caller's context, a queued op's command span
36+
// would parent to a sibling op instead of its own span.
37+
await Promise.all(
38+
['a', 'b', 'c'].map(marker =>
39+
Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })),
40+
),
41+
);
42+
} finally {
43+
await client.close();
44+
}
45+
},
46+
);
47+
}
48+
49+
run();
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { MongoMemoryServer } from 'mongodb-memory-server-global';
3+
import { afterAll, beforeAll, describe, expect } from 'vitest';
4+
import { isOrchestrionEnabled } from '../../../utils';
5+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
6+
7+
// Pins mongodb 4 so the = 4.0 <6.4 callback-based command band and the
8+
// pool-checkout context propagation are exercised against a real mongodb.
9+
describe('MongoDB v4 auto-instrumentation', () => {
10+
let mongoServer: MongoMemoryServer;
11+
12+
beforeAll(async () => {
13+
mongoServer = await MongoMemoryServer.create();
14+
process.env.MONGO_URL = mongoServer.getUri();
15+
}, 30000);
16+
17+
afterAll(async () => {
18+
if (mongoServer) {
19+
await mongoServer.stop();
20+
}
21+
cleanupChildProcesses();
22+
});
23+
24+
const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo';
25+
26+
const spanFor = (operation: string): unknown =>
27+
expect.objectContaining({
28+
data: expect.objectContaining({
29+
'sentry.origin': origin,
30+
'sentry.op': 'db',
31+
'db.system': 'mongodb',
32+
'db.name': 'admin',
33+
'db.mongodb.collection': 'movies',
34+
'db.operation': operation,
35+
'db.connection_string': expect.any(String),
36+
'db.statement': expect.any(String),
37+
}),
38+
op: 'db',
39+
origin,
40+
});
41+
42+
createEsmAndCjsTests(
43+
__dirname,
44+
'scenario.mjs',
45+
'instrument.mjs',
46+
(createTestRunner, test) => {
47+
test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => {
48+
await createTestRunner()
49+
.expect({
50+
transaction: (event: TransactionEvent) => {
51+
const spans = event.spans || [];
52+
expect(spans).toContainEqual(spanFor('insert'));
53+
expect(spans).toContainEqual(spanFor('find'));
54+
expect(spans).toContainEqual(spanFor('update'));
55+
56+
// Checkout context propagation: each `op-*` span must be the
57+
// parent of its own find command. A lost context would collapse
58+
// them onto one parent (or the transaction).
59+
const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id));
60+
expect(opIds.size).toBe(3);
61+
const pooledFinds = spans.filter(
62+
s =>
63+
s.origin === origin &&
64+
(s.data as Record<string, unknown>)?.['db.operation'] === 'find' &&
65+
opIds.has(s.parent_span_id as string),
66+
);
67+
expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3);
68+
},
69+
})
70+
.start()
71+
.completed();
72+
});
73+
},
74+
{ additionalDependencies: { mongodb: '4.17.2' } },
75+
);
76+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as Sentry from '@sentry/node';
2+
import mongodb from 'mongodb';
3+
4+
const { MongoClient } = mongodb;
5+
6+
// maxPoolSize: 1 so the concurrent block below contends for the single
7+
// pooled connection. The queued checkouts resolve from the pool's detached
8+
// context, exercising the checkout context patch.
9+
const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 });
10+
11+
async function run() {
12+
await Sentry.startSpan(
13+
{
14+
name: 'Test Transaction',
15+
op: 'transaction',
16+
},
17+
async () => {
18+
try {
19+
await client.connect();
20+
21+
const collection = client.db('admin').collection('movies');
22+
23+
await collection.insertOne({ title: 'Rick and Morty' });
24+
await collection.findOne({ title: 'Back to the Future' });
25+
await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } });
26+
await collection.find({ title: 'South Park' }).toArray();
27+
28+
// A query the server rejects, to exercise the error-status span path.
29+
await collection
30+
.find({ $thisOperatorDoesNotExist: 1 })
31+
.toArray()
32+
.catch(() => {});
33+
34+
// Pool-contention: each op runs under its own span. If the pooled
35+
// checkout loses the caller's context, a queued op's command span
36+
// would parent to a sibling op instead of its own span.
37+
await Promise.all(
38+
['a', 'b', 'c'].map(marker =>
39+
Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })),
40+
),
41+
);
42+
} finally {
43+
await client.close();
44+
}
45+
},
46+
);
47+
}
48+
49+
run();
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { MongoMemoryServer } from 'mongodb-memory-server-global';
3+
import { afterAll, beforeAll, describe, expect } from 'vitest';
4+
import { isOrchestrionEnabled } from '../../../utils';
5+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
6+
7+
// Pins mongodb 5 so the >= 4.0 < 6.4 callback-based command band and the
8+
// pool-checkout context propagation are exercised against a real mongodb.
9+
describe('MongoDB v5 auto-instrumentation', () => {
10+
let mongoServer: MongoMemoryServer;
11+
12+
beforeAll(async () => {
13+
mongoServer = await MongoMemoryServer.create();
14+
process.env.MONGO_URL = mongoServer.getUri();
15+
}, 30000);
16+
17+
afterAll(async () => {
18+
if (mongoServer) {
19+
await mongoServer.stop();
20+
}
21+
cleanupChildProcesses();
22+
});
23+
24+
const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo';
25+
26+
const spanFor = (operation: string): unknown =>
27+
expect.objectContaining({
28+
data: expect.objectContaining({
29+
'sentry.origin': origin,
30+
'sentry.op': 'db',
31+
'db.system': 'mongodb',
32+
'db.name': 'admin',
33+
'db.mongodb.collection': 'movies',
34+
'db.operation': operation,
35+
'db.connection_string': expect.any(String),
36+
'db.statement': expect.any(String),
37+
}),
38+
op: 'db',
39+
origin,
40+
});
41+
42+
createEsmAndCjsTests(
43+
__dirname,
44+
'scenario.mjs',
45+
'instrument.mjs',
46+
(createTestRunner, test) => {
47+
test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => {
48+
await createTestRunner()
49+
.expect({
50+
transaction: (event: TransactionEvent) => {
51+
const spans = event.spans || [];
52+
expect(spans).toContainEqual(spanFor('insert'));
53+
expect(spans).toContainEqual(spanFor('find'));
54+
expect(spans).toContainEqual(spanFor('update'));
55+
56+
// Checkout context propagation: each `op-*` span must be the
57+
// parent of its own find command. A lost context would collapse
58+
// them onto one parent (or the transaction).
59+
const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id));
60+
expect(opIds.size).toBe(3);
61+
const pooledFinds = spans.filter(
62+
s =>
63+
s.origin === origin &&
64+
(s.data as Record<string, unknown>)?.['db.operation'] === 'find' &&
65+
opIds.has(s.parent_span_id as string),
66+
);
67+
expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3);
68+
},
69+
})
70+
.start()
71+
.completed();
72+
});
73+
},
74+
{ additionalDependencies: { mongodb: '5.9.2' } },
75+
);
76+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as Sentry from '@sentry/node';
2+
import mongodb from 'mongodb';
3+
4+
const { MongoClient } = mongodb;
5+
6+
const client = new MongoClient(process.env.MONGO_URL || '');
7+
8+
async function run() {
9+
await Sentry.startSpan(
10+
{
11+
name: 'Test Transaction',
12+
op: 'transaction',
13+
},
14+
async () => {
15+
try {
16+
await client.connect();
17+
18+
const collection = client.db('admin').collection('movies');
19+
20+
await collection.insertOne({ title: 'Rick and Morty' });
21+
await collection.findOne({ title: 'Back to the Future' });
22+
await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } });
23+
await collection.find({ title: 'South Park' }).toArray();
24+
25+
// A query the server rejects, to exercise the error-status span path.
26+
await collection
27+
.find({ $thisOperatorDoesNotExist: 1 })
28+
.toArray()
29+
.catch(() => {});
30+
} finally {
31+
await client.close();
32+
}
33+
},
34+
);
35+
}
36+
37+
run();
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { MongoMemoryServer } from 'mongodb-memory-server-global';
3+
import { afterAll, beforeAll, describe, expect } from 'vitest';
4+
import { isOrchestrionEnabled } from '../../../utils';
5+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
6+
7+
// Pins mongodb 6 so the >= 6.4 promise-based `Connection.prototype.command`
8+
// band is exercised against a real mongodb.
9+
describe('MongoDB v6 auto-instrumentation', () => {
10+
let mongoServer: MongoMemoryServer;
11+
12+
beforeAll(async () => {
13+
mongoServer = await MongoMemoryServer.create();
14+
process.env.MONGO_URL = mongoServer.getUri();
15+
}, 30000);
16+
17+
afterAll(async () => {
18+
if (mongoServer) {
19+
await mongoServer.stop();
20+
}
21+
cleanupChildProcesses();
22+
});
23+
24+
const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo';
25+
26+
// `db.statement` (scrubbed full command doc) and `db.connection_string` vary
27+
// by driver version, so assert their presence rather than exact content;
28+
// the operation-identifying attributes are exact.
29+
const spanFor = (operation: string): unknown =>
30+
expect.objectContaining({
31+
data: expect.objectContaining({
32+
'sentry.origin': origin,
33+
'sentry.op': 'db',
34+
'db.system': 'mongodb',
35+
'db.name': 'admin',
36+
'db.mongodb.collection': 'movies',
37+
'db.operation': operation,
38+
'db.connection_string': expect.any(String),
39+
'db.statement': expect.any(String),
40+
}),
41+
op: 'db',
42+
origin,
43+
});
44+
45+
createEsmAndCjsTests(
46+
__dirname,
47+
'scenario.mjs',
48+
'instrument.mjs',
49+
(createTestRunner, test) => {
50+
test('auto-instruments modern `mongodb` (>= 6.4 promise command).', async () => {
51+
await createTestRunner()
52+
.expect({
53+
transaction: (event: TransactionEvent) => {
54+
const spans = event.spans || [];
55+
expect(spans).toContainEqual(spanFor('insert'));
56+
expect(spans).toContainEqual(spanFor('find'));
57+
expect(spans).toContainEqual(spanFor('update'));
58+
// No orphaned handshake/heartbeat spans!
59+
// every command span has a parent.
60+
const mongoSpans = spans.filter(s => s.origin === origin);
61+
expect(mongoSpans.length).toBeGreaterThan(0);
62+
for (const s of mongoSpans) {
63+
expect(s.parent_span_id).toBeTruthy();
64+
}
65+
},
66+
})
67+
.start()
68+
.completed();
69+
});
70+
},
71+
{ additionalDependencies: { mongodb: '6.21.0' } },
72+
);
73+
});

0 commit comments

Comments
 (0)