Skip to content

Commit 6efe345

Browse files
committed
feat: MongoDB Tracing Support
1 parent 24c5c28 commit 6efe345

File tree

3 files changed

+215
-1
lines changed

3 files changed

+215
-1
lines changed

packages/tracing/src/integrations/express.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export class Express implements Integration {
7979
*/
8080
public setupOnce(): void {
8181
if (!this._router) {
82-
logger.error('ExpressIntegration is missing an Express instance');
82+
logger.error('Express Integration is missing an Express instance');
8383
return;
8484
}
8585
instrumentMiddlewares(this._router, this._methods);
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: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { Hub } from '@sentry/hub';
2+
import { EventProcessor, Integration, SpanContext } from '@sentry/types';
3+
import { 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+
/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access */
114+
const mongodbModule = require('mongodb');
115+
collection = mongodbModule.Collection;
116+
/* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access */
117+
} catch (e) {
118+
logger.error('Mongo Integration was unable to require `mongodb` package.');
119+
return;
120+
}
121+
122+
this._instrumentOperations(collection, this._operations, getCurrentHub);
123+
}
124+
125+
/**
126+
* Patches original collection methods
127+
*/
128+
private _instrumentOperations(collection: MongoCollection, operations: Operation[], getCurrentHub: () => Hub): void {
129+
operations.forEach((operation: Operation) => this._patchOperation(collection, operation, getCurrentHub));
130+
}
131+
132+
/**
133+
* Patches original collection to utilize our tracing functionality
134+
*/
135+
private _patchOperation(collection: MongoCollection, operation: Operation, getCurrentHub: () => Hub): void {
136+
if (!(operation in collection.prototype)) return;
137+
138+
const getSpanContext = this._getSpanContextFromOperationArguments.bind(this);
139+
140+
fill(collection.prototype, operation, function(orig: () => void | Promise<unknown>) {
141+
return function(this: unknown, ...args: unknown[]) {
142+
const lastArg = args[args.length - 1];
143+
const scope = getCurrentHub().getScope();
144+
const transaction = scope?.getTransaction();
145+
146+
// mapReduce is a special edge-case, as it's the only operation that accepts functions
147+
// other than the callback as it's own arguments. Therefore despite lastArg being
148+
// a function, it can be still a promise-based call without a callback.
149+
// mapReduce(map, reduce, options, callback) where `[map|reduce]: function | string`
150+
if (typeof lastArg !== 'function' || (operation === 'mapReduce' && args.length === 2)) {
151+
const span = transaction?.startChild(getSpanContext(this, operation, args));
152+
return (orig.call(this, ...args) as Promise<unknown>).then((res: unknown) => {
153+
span?.finish();
154+
return res;
155+
});
156+
}
157+
158+
const span = transaction?.startChild(getSpanContext(this, operation, args.slice(0, -1)));
159+
return orig.call(this, ...args.slice(0, -1), function(err: Error, result: unknown) {
160+
span?.finish();
161+
lastArg(err, result);
162+
});
163+
};
164+
});
165+
}
166+
167+
/**
168+
* Form a SpanContext based on the user input to a given operation.
169+
*/
170+
private _getSpanContextFromOperationArguments(
171+
collection: MongoCollection,
172+
operation: Operation,
173+
args: unknown[],
174+
): SpanContext {
175+
const data: { [key: string]: string } = {
176+
collectionName: collection.collectionName,
177+
dbName: collection.dbName,
178+
namespace: collection.namespace,
179+
};
180+
const spanContext: SpanContext = {
181+
op: `query.${operation}`,
182+
data,
183+
};
184+
185+
// If there was no signature available for us to be used for the extracted data description.
186+
// Or user decided to not describe given operation, just return early.
187+
const signature = OPERATION_SIGNATURES[operation];
188+
const shouldDescribe = Array.isArray(this._describeOperations)
189+
? this._describeOperations.includes(operation)
190+
: this._describeOperations;
191+
192+
if (!signature || !shouldDescribe) {
193+
return spanContext;
194+
}
195+
196+
try {
197+
// Special case for `mapReduce`, as the only one accepting functions as arguments.
198+
if (operation === 'mapReduce') {
199+
const [map, reduce] = args as { name?: string }[];
200+
data[signature[0]] = typeof map === 'string' ? map : map.name || '<anonymous>';
201+
data[signature[1]] = typeof reduce === 'string' ? reduce : reduce.name || '<anonymous>';
202+
} else {
203+
for (let i = 0; i < signature.length; i++) {
204+
data[signature[i]] = JSON.stringify(args[i]);
205+
}
206+
}
207+
} catch (_oO) {
208+
// no-empty
209+
}
210+
211+
return spanContext;
212+
}
213+
}

0 commit comments

Comments
 (0)