Skip to content

Commit d3e56c6

Browse files
committed
feat: MongoDB Tracing Support
1 parent 7dd42bc commit d3e56c6

File tree

3 files changed

+236
-1
lines changed

3 files changed

+236
-1
lines changed

packages/tracing/src/integrations/express.ts

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

0 commit comments

Comments
 (0)