Skip to content

Commit ffe8485

Browse files
authored
fix(instrumentation-mongodb): maximum call stack exceeded in scrub statement (#3131)
1 parent b5286c8 commit ffe8485

File tree

4 files changed

+305
-21
lines changed

4 files changed

+305
-21
lines changed

packages/instrumentation-mongodb/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"compile": "tsc -p .",
2020
"prepublishOnly": "npm run compile",
2121
"tdd": "npm run test-v5-v6-run -- --watch-extensions ts --watch",
22-
"test": "npm run test-v5-v6",
22+
"test": "npm run test-v5-v6 && npm run test-unit",
23+
"test-unit": "nyc --no-clean mocha --require '@opentelemetry/contrib-test-utils' 'test/**/unit/*.test.ts'",
2324
"test-v3": "nyc --no-clean mocha --require '@opentelemetry/contrib-test-utils' 'test/**/mongodb-v3.test.ts'",
2425
"test-v4": "nyc --no-clean mocha --require '@opentelemetry/contrib-test-utils' 'test/mongodb-v4-v5-v6.metrics.test.ts' 'test/**/mongodb-v4.test.ts'",
2526
"test-v5-v6": "nyc --no-clean mocha --require '@opentelemetry/contrib-test-utils' 'test/mongodb-v4-v5-v6.metrics.test.ts' 'test/**/mongodb-v5-v6.test.ts'",
@@ -75,4 +76,4 @@
7576
"@opentelemetry/instrumentation": "^0.205.0"
7677
},
7778
"homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-mongodb#readme"
78-
}
79+
}

packages/instrumentation-mongodb/src/instrumentation.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
WireProtocolInternal,
5151
V4Connection,
5252
V4ConnectionPool,
53+
Replacer,
5354
} from './internal-types';
5455
import { V4Connect, V4Session } from './internal-types';
5556
/** @knipignore */
@@ -951,30 +952,27 @@ export class MongoDBInstrumentation extends InstrumentationBase<MongoDBInstrumen
951952
);
952953
}
953954

954-
private _defaultDbStatementSerializer(commandObj: Record<string, unknown>) {
955-
const { enhancedDatabaseReporting } = this.getConfig();
956-
const resultObj = enhancedDatabaseReporting
957-
? commandObj
958-
: this._scrubStatement(commandObj);
959-
return JSON.stringify(resultObj);
955+
private _getDefaultDbStatementReplacer(): Replacer {
956+
const seen = new WeakSet();
957+
return (_key, value) => {
958+
// undefined, boolean, number, bigint, string, symbol, function || null
959+
if (typeof value !== 'object' || !value) return '?';
960+
961+
// objects (including arrays)
962+
if (seen.has(value)) return '[Circular]';
963+
seen.add(value);
964+
return value;
965+
};
960966
}
961967

962-
private _scrubStatement(value: unknown): unknown {
963-
if (Array.isArray(value)) {
964-
return value.map(element => this._scrubStatement(element));
965-
}
968+
private _defaultDbStatementSerializer(commandObj: Record<string, unknown>) {
969+
const { enhancedDatabaseReporting } = this.getConfig();
966970

967-
if (typeof value === 'object' && value !== null) {
968-
return Object.fromEntries(
969-
Object.entries(value).map(([key, element]) => [
970-
key,
971-
this._scrubStatement(element),
972-
])
973-
);
971+
if (enhancedDatabaseReporting) {
972+
return JSON.stringify(commandObj);
974973
}
975974

976-
// A value like string or number, possible contains PII, scrub it
977-
return '?';
975+
return JSON.stringify(commandObj, this._getDefaultDbStatementReplacer());
978976
}
979977

980978
/**

packages/instrumentation-mongodb/src/internal-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,8 @@ export type V4Session = {
226226
acquire: () => ServerSession;
227227
release: (session: ServerSession) => void;
228228
};
229+
230+
/**
231+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#replacer
232+
*/
233+
export type Replacer = (key: string, value: unknown) => unknown;
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as assert from 'assert';
18+
import { MongoDBInstrumentation } from '../../src';
19+
20+
describe('DbStatementSerializer', () => {
21+
let instrumentation: MongoDBInstrumentation;
22+
23+
beforeEach(() => {
24+
instrumentation = new MongoDBInstrumentation();
25+
});
26+
27+
const testDefaultDbStatementSerialization = (
28+
commandObj: Record<string, unknown>,
29+
config: any
30+
): string => {
31+
instrumentation.setConfig(config);
32+
return (instrumentation as any)._defaultDbStatementSerializer(commandObj);
33+
};
34+
35+
describe('default db statement serialization', () => {
36+
it('should handle deeply nested objects without stack overflow', () => {
37+
// Create a deeply nested object that could cause stack overflow
38+
let deeplyNested: any = { value: 'test' };
39+
for (let i = 0; i < 500; i++) {
40+
deeplyNested = { level: i, nested: deeplyNested };
41+
}
42+
43+
const commandObj = {
44+
insert: 'test-collection',
45+
documents: [deeplyNested],
46+
};
47+
48+
const result = testDefaultDbStatementSerialization(commandObj, {
49+
enhancedDatabaseReporting: false,
50+
});
51+
52+
assert(typeof result === 'string', 'Result should be a string');
53+
assert(result!.length > 0, 'Result should not be empty');
54+
});
55+
56+
it('should handle circular references gracefully with replacer function', () => {
57+
// Create an object with circular reference
58+
const circularObj: any = { a: 1, b: 2 };
59+
circularObj.circular = circularObj;
60+
61+
const commandObj = {
62+
insert: 'test-collection',
63+
documents: [circularObj],
64+
};
65+
66+
const result = testDefaultDbStatementSerialization(commandObj, {
67+
enhancedDatabaseReporting: false,
68+
});
69+
70+
assert(typeof result === 'string', 'Result should be a string');
71+
assert(result.length > 0, 'Result should not be empty');
72+
73+
const parsed = JSON.parse(result);
74+
assert.strictEqual(parsed.insert, '?', 'Values should be scrubbed');
75+
assert.strictEqual(
76+
parsed.documents[0].circular,
77+
'[Circular]',
78+
'Circular reference should be marked as [Circular]'
79+
);
80+
});
81+
82+
it('should handle enhancedDatabaseReporting: true with circular references', () => {
83+
const circularObj: any = { a: 1 };
84+
circularObj.circular = circularObj;
85+
86+
const commandObj = {
87+
insert: 'test-collection',
88+
documents: [circularObj],
89+
};
90+
91+
// With enhancedDatabaseReporting: true, it uses simple JSON.stringify which should throw
92+
assert.throws(
93+
() => {
94+
testDefaultDbStatementSerialization(commandObj, {
95+
enhancedDatabaseReporting: true,
96+
});
97+
},
98+
/Converting circular structure to JSON/,
99+
'Should throw on circular references with enhancedDatabaseReporting: true'
100+
);
101+
});
102+
103+
it('should scrub values when enhancedDatabaseReporting is false', () => {
104+
const commandObj = {
105+
insert: 'test-collection',
106+
documents: [{ name: 'John', age: 30 }],
107+
};
108+
109+
const result = testDefaultDbStatementSerialization(commandObj, {
110+
enhancedDatabaseReporting: false,
111+
});
112+
113+
const expected = JSON.stringify({
114+
insert: '?',
115+
documents: [{ name: '?', age: '?' }],
116+
});
117+
118+
assert.strictEqual(result, expected, 'All values should be scrubbed');
119+
});
120+
121+
it('should not scrub values when enhancedDatabaseReporting is true', () => {
122+
const commandObj = {
123+
insert: 'test-collection',
124+
documents: [{ name: 'John', age: 30 }],
125+
};
126+
127+
const expected = JSON.stringify(commandObj);
128+
const actualResult = testDefaultDbStatementSerialization(commandObj, {
129+
enhancedDatabaseReporting: true,
130+
});
131+
132+
assert.strictEqual(
133+
actualResult,
134+
expected,
135+
'Values should not be scrubbed'
136+
);
137+
});
138+
139+
it('should handle arrays properly', () => {
140+
const commandObj = {
141+
find: 'users',
142+
filter: { $or: ['condition1', 'condition2', { nested: 'value' }] },
143+
};
144+
145+
const result = testDefaultDbStatementSerialization(commandObj, {
146+
enhancedDatabaseReporting: false,
147+
});
148+
149+
const expected = JSON.stringify({
150+
find: '?',
151+
filter: { $or: ['?', '?', { nested: '?' }] },
152+
});
153+
154+
assert.strictEqual(result, expected, 'Array values should be scrubbed');
155+
});
156+
157+
it('should handle objects properly', () => {
158+
const commandObj = {
159+
filter: { category: 'electronics' },
160+
update: { $set: { price: 299.99, available: true } },
161+
};
162+
163+
const result = testDefaultDbStatementSerialization(commandObj, {
164+
enhancedDatabaseReporting: false,
165+
});
166+
167+
const expected = JSON.stringify({
168+
filter: { category: '?' },
169+
update: { $set: { price: '?', available: '?' } },
170+
});
171+
172+
assert.strictEqual(result, expected, 'Object values should be scrubbed');
173+
});
174+
175+
it('should handle bigint values', () => {
176+
const commandObj = {
177+
insert: 'analytics',
178+
documents: [
179+
{
180+
userId: BigInt(123456789012345),
181+
timestamp: BigInt(Date.now()),
182+
},
183+
],
184+
};
185+
186+
const result = testDefaultDbStatementSerialization(commandObj, {
187+
enhancedDatabaseReporting: false,
188+
});
189+
190+
const expected = JSON.stringify({
191+
insert: '?',
192+
documents: [{ userId: '?', timestamp: '?' }],
193+
});
194+
195+
assert.strictEqual(result, expected, 'BigInt values should be scrubbed');
196+
});
197+
198+
it('should handle deep nested objects', () => {
199+
const commandObj = {
200+
aggregate: 'orders',
201+
pipeline: [
202+
{
203+
$match: {
204+
user: {
205+
profile: {
206+
preferences: {
207+
notifications: {
208+
email: true,
209+
sms: false,
210+
push: {
211+
enabled: true,
212+
frequency: 'daily',
213+
categories: ['updates', 'promotions'],
214+
},
215+
},
216+
},
217+
},
218+
},
219+
},
220+
},
221+
],
222+
};
223+
224+
const result = testDefaultDbStatementSerialization(commandObj, {
225+
enhancedDatabaseReporting: false,
226+
});
227+
228+
const expected = JSON.stringify({
229+
aggregate: '?',
230+
pipeline: [
231+
{
232+
$match: {
233+
user: {
234+
profile: {
235+
preferences: {
236+
notifications: {
237+
email: '?',
238+
sms: '?',
239+
push: {
240+
enabled: '?',
241+
frequency: '?',
242+
categories: ['?', '?'],
243+
},
244+
},
245+
},
246+
},
247+
},
248+
},
249+
},
250+
],
251+
});
252+
253+
assert.strictEqual(
254+
result,
255+
expected,
256+
'Deep nested values should be scrubbed while preserving structure'
257+
);
258+
});
259+
260+
it('should handle string values', () => {
261+
const commandObj = {
262+
find: 'messages',
263+
filter: { content: 'hello world', sender: 'user123' },
264+
sort: { timestamp: -1 },
265+
};
266+
267+
const result = testDefaultDbStatementSerialization(commandObj, {
268+
enhancedDatabaseReporting: false,
269+
});
270+
271+
const expected = JSON.stringify({
272+
find: '?',
273+
filter: { content: '?', sender: '?' },
274+
sort: { timestamp: '?' },
275+
});
276+
277+
assert.strictEqual(result, expected, 'String values should be scrubbed');
278+
});
279+
});
280+
});

0 commit comments

Comments
 (0)