Skip to content

Commit 17ed7b9

Browse files
committed
feat: MongoDB Tracing Support
1 parent 24c5c28 commit 17ed7b9

File tree

2 files changed

+212
-0
lines changed

2 files changed

+212
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { Express } from './express';
2+
export { Mongo } from './mongo';
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { Hub } from '@sentry/hub';
2+
import { EventProcessor, Integration, SpanContext } from '@sentry/types';
3+
import { dynamicRequire, fill, logger } from '@sentry/utils';
4+
5+
// This allows us to use the same array for both, defaults option and the type itself.
6+
// (note `as const` at the end to make it a concrete union type, and not just string[])
7+
type Operation = typeof OPERATIONS[number];
8+
const OPERATIONS = [
9+
'aggregate', // aggregate(pipeline, options, callback)
10+
'bulkWrite', // bulkWrite(operations, options, callback)
11+
'countDocuments', // countDocuments(query, options, callback)
12+
'createIndex', // createIndex(fieldOrSpec, options, callback)
13+
'createIndexes', // createIndexes(indexSpecs, options, callback)
14+
'deleteMany', // deleteMany(filter, options, callback)
15+
'deleteOne', // deleteOne(filter, options, callback)
16+
'distinct', // distinct(key, query, options, callback)
17+
'drop', // drop(options, callback)
18+
'dropIndex', // dropIndex(indexName, options, callback)
19+
'dropIndexes', // dropIndexes(options, callback)
20+
'estimatedDocumentCount', // estimatedDocumentCount(options, callback)
21+
'findOne', // findOne(query, options, callback)
22+
'findOneAndDelete', // findOneAndDelete(filter, options, callback)
23+
'findOneAndReplace', // findOneAndReplace(filter, replacement, options, callback)
24+
'findOneAndUpdate', // findOneAndUpdate(filter, update, options, callback)
25+
'indexes', // indexes(options, callback)
26+
'indexExists', // indexExists(indexes, options, callback)
27+
'indexInformation', // indexInformation(options, callback)
28+
'initializeOrderedBulkOp', // initializeOrderedBulkOp(options, callback)
29+
'insertMany', // insertMany(docs, options, callback)
30+
'insertOne', // insertOne(doc, options, callback)
31+
'isCapped', // isCapped(options, callback)
32+
'mapReduce', // mapReduce(map, reduce, options, callback)
33+
'options', // options(options, callback)
34+
'parallelCollectionScan', // parallelCollectionScan(options, callback)
35+
'rename', // rename(newName, options, callback)
36+
'replaceOne', // replaceOne(filter, doc, options, callback)
37+
'stats', // stats(options, callback)
38+
'updateMany', // updateMany(filter, update, options, callback)
39+
'updateOne', // updateOne(filter, update, options, callback)
40+
] as const;
41+
42+
const OPERATION_SIGNATURES: {
43+
[op in Operation]?: string[];
44+
} = {
45+
bulkWrite: ['operations'],
46+
countDocuments: ['query'],
47+
createIndex: ['fieldOrSpec'],
48+
createIndexes: ['indexSpecs'],
49+
deleteMany: ['filter'],
50+
deleteOne: ['filter'],
51+
distinct: ['key', 'query'],
52+
dropIndex: ['indexName'],
53+
findOne: ['query'],
54+
findOneAndDelete: ['filter'],
55+
findOneAndReplace: ['filter', 'replacement'],
56+
findOneAndUpdate: ['filter', 'update'],
57+
indexExists: ['indexes'],
58+
insertMany: ['docs'],
59+
insertOne: ['doc'],
60+
mapReduce: ['map', 'reduce'],
61+
rename: ['newName'],
62+
replaceOne: ['filter', 'doc'],
63+
updateMany: ['filter', 'update'],
64+
updateOne: ['filter', 'update'],
65+
};
66+
67+
interface MongoCollection {
68+
collectionName: string;
69+
dbName: string;
70+
namespace: string;
71+
prototype: {
72+
[operation in Operation]: (...args: unknown[]) => unknown;
73+
};
74+
}
75+
76+
interface MongoOptions {
77+
operations?: Operation[];
78+
describeOperations?: boolean | Operation[];
79+
}
80+
81+
/** Tracing integration for node-postgres package */
82+
export class Mongo implements Integration {
83+
/**
84+
* @inheritDoc
85+
*/
86+
public static id: string = 'Mongo';
87+
88+
/**
89+
* @inheritDoc
90+
*/
91+
public name: string = Mongo.id;
92+
93+
private _operations: Operation[];
94+
private _describeOperations?: boolean | Operation[];
95+
96+
/**
97+
* @inheritDoc
98+
*/
99+
public constructor(options: MongoOptions = {}) {
100+
this._operations = Array.isArray(options.operations)
101+
? options.operations
102+
: ((OPERATIONS as unknown) as Operation[]);
103+
this._describeOperations = 'describeOperations' in options ? options.describeOperations : true;
104+
}
105+
106+
/**
107+
* @inheritDoc
108+
*/
109+
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
110+
let collection: MongoCollection;
111+
112+
try {
113+
const mongodbModule = dynamicRequire(module, 'mongodb') as { Collection: MongoCollection };
114+
collection = mongodbModule.Collection;
115+
} catch (e) {
116+
logger.error('Mongo Integration was unable to require `mongodb` package.');
117+
return;
118+
}
119+
120+
this._instrumentOperations(collection, this._operations, getCurrentHub);
121+
}
122+
123+
/**
124+
* Patches original collection methods
125+
*/
126+
private _instrumentOperations(collection: MongoCollection, operations: Operation[], getCurrentHub: () => Hub): void {
127+
operations.forEach((operation: Operation) => this._patchOperation(collection, operation, getCurrentHub));
128+
}
129+
130+
/**
131+
* Patches original collection to utilize our tracing functionality
132+
*/
133+
private _patchOperation(collection: MongoCollection, operation: Operation, getCurrentHub: () => Hub): void {
134+
if (!(operation in collection.prototype)) return;
135+
136+
const getSpanContext = this._getSpanContextFromOperationArguments.bind(this);
137+
138+
fill(collection.prototype, operation, function(orig: () => void | Promise<unknown>) {
139+
return function(this: unknown, ...args: unknown[]) {
140+
const lastArg = args[args.length - 1];
141+
const scope = getCurrentHub().getScope();
142+
const transaction = scope?.getTransaction();
143+
144+
// mapReduce is a special edge-case, as it's the only operation that accepts functions
145+
// other than the callback as it's own arguments. Therefore despite lastArg being
146+
// a function, it can be still a promise-based call without a callback.
147+
// mapReduce(map, reduce, options, callback) where `[map|reduce]: function | string`
148+
if (typeof lastArg !== 'function' || (operation === 'mapReduce' && args.length === 2)) {
149+
const span = transaction?.startChild(getSpanContext(this, operation, args));
150+
return (orig.call(this, ...args) as Promise<unknown>).then((res: unknown) => {
151+
span?.finish();
152+
return res;
153+
});
154+
}
155+
156+
const span = transaction?.startChild(getSpanContext(this, operation, args.slice(0, -1)));
157+
return orig.call(this, ...args.slice(0, -1), function(err: Error, result: unknown) {
158+
span?.finish();
159+
lastArg(err, result);
160+
});
161+
};
162+
});
163+
}
164+
165+
/**
166+
* Form a SpanContext based on the user input to a given operation.
167+
*/
168+
private _getSpanContextFromOperationArguments(
169+
collection: MongoCollection,
170+
operation: Operation,
171+
args: unknown[],
172+
): SpanContext {
173+
const data: { [key: string]: string } = {
174+
collectionName: collection.collectionName,
175+
dbName: collection.dbName,
176+
namespace: collection.namespace,
177+
};
178+
const spanContext: SpanContext = {
179+
op: `query.${operation}`,
180+
data,
181+
};
182+
183+
// If there was no signature available for us to be used for the extracted data description.
184+
// Or user decided to not describe given operation, just return early.
185+
const signature = OPERATION_SIGNATURES[operation];
186+
const shouldDescribe = Array.isArray(this._describeOperations)
187+
? this._describeOperations.includes(operation)
188+
: this._describeOperations;
189+
190+
if (!signature || !shouldDescribe) {
191+
return spanContext;
192+
}
193+
194+
try {
195+
// Special case for `mapReduce`, as the only one accepting functions as arguments.
196+
if (operation === 'mapReduce') {
197+
const [map, reduce] = args as { name?: string }[];
198+
data[signature[0]] = typeof map === 'string' ? map : map.name || '<anonymous>';
199+
data[signature[1]] = typeof reduce === 'string' ? reduce : reduce.name || '<anonymous>';
200+
} else {
201+
for (let i = 0; i < signature.length; i++) {
202+
data[signature[i]] = JSON.stringify(args[i]);
203+
}
204+
}
205+
} catch (_oO) {
206+
// no-empty
207+
}
208+
209+
return spanContext;
210+
}
211+
}

0 commit comments

Comments
 (0)