|
| 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 options and the type itself. |
| 6 | +// (note `as const` at the end to make it a union of string literal types (i.e. "a" | "b" | ... ) |
| 7 | +// and not just a string[]) |
| 8 | +type Operation = typeof OPERATIONS[number]; |
| 9 | +const OPERATIONS = [ |
| 10 | + 'aggregate', // aggregate(pipeline, options, callback) |
| 11 | + 'bulkWrite', // bulkWrite(operations, options, callback) |
| 12 | + 'countDocuments', // countDocuments(query, options, callback) |
| 13 | + 'createIndex', // createIndex(fieldOrSpec, options, callback) |
| 14 | + 'createIndexes', // createIndexes(indexSpecs, options, callback) |
| 15 | + 'deleteMany', // deleteMany(filter, options, callback) |
| 16 | + 'deleteOne', // deleteOne(filter, options, callback) |
| 17 | + 'distinct', // distinct(key, query, options, callback) |
| 18 | + 'drop', // drop(options, callback) |
| 19 | + 'dropIndex', // dropIndex(indexName, options, callback) |
| 20 | + 'dropIndexes', // dropIndexes(options, callback) |
| 21 | + 'estimatedDocumentCount', // estimatedDocumentCount(options, callback) |
| 22 | + 'findOne', // findOne(query, options, callback) |
| 23 | + 'findOneAndDelete', // findOneAndDelete(filter, options, callback) |
| 24 | + 'findOneAndReplace', // findOneAndReplace(filter, replacement, options, callback) |
| 25 | + 'findOneAndUpdate', // findOneAndUpdate(filter, update, options, callback) |
| 26 | + 'indexes', // indexes(options, callback) |
| 27 | + 'indexExists', // indexExists(indexes, options, callback) |
| 28 | + 'indexInformation', // indexInformation(options, callback) |
| 29 | + 'initializeOrderedBulkOp', // initializeOrderedBulkOp(options, callback) |
| 30 | + 'insertMany', // insertMany(docs, options, callback) |
| 31 | + 'insertOne', // insertOne(doc, options, callback) |
| 32 | + 'isCapped', // isCapped(options, callback) |
| 33 | + 'mapReduce', // mapReduce(map, reduce, options, callback) |
| 34 | + 'options', // options(options, callback) |
| 35 | + 'parallelCollectionScan', // parallelCollectionScan(options, callback) |
| 36 | + 'rename', // rename(newName, options, callback) |
| 37 | + 'replaceOne', // replaceOne(filter, doc, options, callback) |
| 38 | + 'stats', // stats(options, callback) |
| 39 | + 'updateMany', // updateMany(filter, update, options, callback) |
| 40 | + 'updateOne', // updateOne(filter, update, options, callback) |
| 41 | +] as const; |
| 42 | + |
| 43 | +// All of the operations above take `options` and `callback` as their final parameters, but some of them |
| 44 | +// take additional parameters as well. For those operations, this is a map of |
| 45 | +// { <operation name>: [<names of additional parameters>] }, as a way to know what to call the operation's |
| 46 | +// positional arguments when we add them to the span's `data` object later |
| 47 | +const OPERATION_SIGNATURES: { |
| 48 | + [op in Operation]?: string[]; |
| 49 | +} = { |
| 50 | + aggregate: ['pipeline'], |
| 51 | + bulkWrite: ['operations'], |
| 52 | + countDocuments: ['query'], |
| 53 | + createIndex: ['fieldOrSpec'], |
| 54 | + createIndexes: ['indexSpecs'], |
| 55 | + deleteMany: ['filter'], |
| 56 | + deleteOne: ['filter'], |
| 57 | + distinct: ['key', 'query'], |
| 58 | + dropIndex: ['indexName'], |
| 59 | + findOne: ['query'], |
| 60 | + findOneAndDelete: ['filter'], |
| 61 | + findOneAndReplace: ['filter', 'replacement'], |
| 62 | + findOneAndUpdate: ['filter', 'update'], |
| 63 | + indexExists: ['indexes'], |
| 64 | + insertMany: ['docs'], |
| 65 | + insertOne: ['doc'], |
| 66 | + mapReduce: ['map', 'reduce'], |
| 67 | + rename: ['newName'], |
| 68 | + replaceOne: ['filter', 'doc'], |
| 69 | + updateMany: ['filter', 'update'], |
| 70 | + updateOne: ['filter', 'update'], |
| 71 | +}; |
| 72 | + |
| 73 | +interface MongoCollection { |
| 74 | + collectionName: string; |
| 75 | + dbName: string; |
| 76 | + namespace: string; |
| 77 | + prototype: { |
| 78 | + [operation in Operation]: (...args: unknown[]) => unknown; |
| 79 | + }; |
| 80 | +} |
| 81 | + |
| 82 | +interface MongoOptions { |
| 83 | + operations?: Operation[]; |
| 84 | + describeOperations?: boolean | Operation[]; |
| 85 | +} |
| 86 | + |
| 87 | +/** Tracing integration for mongo package */ |
| 88 | +export class Mongo implements Integration { |
| 89 | + /** |
| 90 | + * @inheritDoc |
| 91 | + */ |
| 92 | + public static id: string = 'Mongo'; |
| 93 | + |
| 94 | + /** |
| 95 | + * @inheritDoc |
| 96 | + */ |
| 97 | + public name: string = Mongo.id; |
| 98 | + |
| 99 | + private _operations: Operation[]; |
| 100 | + private _describeOperations?: boolean | Operation[]; |
| 101 | + |
| 102 | + /** |
| 103 | + * @inheritDoc |
| 104 | + */ |
| 105 | + public constructor(options: MongoOptions = {}) { |
| 106 | + this._operations = Array.isArray(options.operations) |
| 107 | + ? options.operations |
| 108 | + : ((OPERATIONS as unknown) as Operation[]); |
| 109 | + this._describeOperations = 'describeOperations' in options ? options.describeOperations : true; |
| 110 | + } |
| 111 | + |
| 112 | + /** |
| 113 | + * @inheritDoc |
| 114 | + */ |
| 115 | + public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { |
| 116 | + let collection: MongoCollection; |
| 117 | + |
| 118 | + try { |
| 119 | + const mongodbModule = dynamicRequire(module, 'mongodb') as { Collection: MongoCollection }; |
| 120 | + collection = mongodbModule.Collection; |
| 121 | + } catch (e) { |
| 122 | + logger.error('Mongo Integration was unable to require `mongodb` package.'); |
| 123 | + return; |
| 124 | + } |
| 125 | + |
| 126 | + this._instrumentOperations(collection, this._operations, getCurrentHub); |
| 127 | + } |
| 128 | + |
| 129 | + /** |
| 130 | + * Patches original collection methods |
| 131 | + */ |
| 132 | + private _instrumentOperations(collection: MongoCollection, operations: Operation[], getCurrentHub: () => Hub): void { |
| 133 | + operations.forEach((operation: Operation) => this._patchOperation(collection, operation, getCurrentHub)); |
| 134 | + } |
| 135 | + |
| 136 | + /** |
| 137 | + * Patches original collection to utilize our tracing functionality |
| 138 | + */ |
| 139 | + private _patchOperation(collection: MongoCollection, operation: Operation, getCurrentHub: () => Hub): void { |
| 140 | + if (!(operation in collection.prototype)) return; |
| 141 | + |
| 142 | + const getSpanContext = this._getSpanContextFromOperationArguments.bind(this); |
| 143 | + |
| 144 | + fill(collection.prototype, operation, function(orig: () => void | Promise<unknown>) { |
| 145 | + return function(this: unknown, ...args: unknown[]) { |
| 146 | + const lastArg = args[args.length - 1]; |
| 147 | + const scope = getCurrentHub().getScope(); |
| 148 | + const parentSpan = scope?.getSpan(); |
| 149 | + |
| 150 | + // Check if the operation was passed a callback. (mapReduce requires a different check, as |
| 151 | + // its (non-callback) arguments can also be functions.) |
| 152 | + if (typeof lastArg !== 'function' || (operation === 'mapReduce' && args.length === 2)) { |
| 153 | + const span = parentSpan?.startChild(getSpanContext(this, operation, args)); |
| 154 | + return (orig.call(this, ...args) as Promise<unknown>).then((res: unknown) => { |
| 155 | + span?.finish(); |
| 156 | + return res; |
| 157 | + }); |
| 158 | + } |
| 159 | + |
| 160 | + const span = parentSpan?.startChild(getSpanContext(this, operation, args.slice(0, -1))); |
| 161 | + return orig.call(this, ...args.slice(0, -1), function(err: Error, result: unknown) { |
| 162 | + span?.finish(); |
| 163 | + lastArg(err, result); |
| 164 | + }); |
| 165 | + }; |
| 166 | + }); |
| 167 | + } |
| 168 | + |
| 169 | + /** |
| 170 | + * Form a SpanContext based on the user input to a given operation. |
| 171 | + */ |
| 172 | + private _getSpanContextFromOperationArguments( |
| 173 | + collection: MongoCollection, |
| 174 | + operation: Operation, |
| 175 | + args: unknown[], |
| 176 | + ): SpanContext { |
| 177 | + const data: { [key: string]: string } = { |
| 178 | + collectionName: collection.collectionName, |
| 179 | + dbName: collection.dbName, |
| 180 | + namespace: collection.namespace, |
| 181 | + }; |
| 182 | + const spanContext: SpanContext = { |
| 183 | + op: `db`, |
| 184 | + description: operation, |
| 185 | + data, |
| 186 | + }; |
| 187 | + |
| 188 | + // If the operation takes no arguments besides `options` and `callback`, or if argument |
| 189 | + // collection is disabled for this operation, just return early. |
| 190 | + const signature = OPERATION_SIGNATURES[operation]; |
| 191 | + const shouldDescribe = Array.isArray(this._describeOperations) |
| 192 | + ? this._describeOperations.includes(operation) |
| 193 | + : this._describeOperations; |
| 194 | + |
| 195 | + if (!signature || !shouldDescribe) { |
| 196 | + return spanContext; |
| 197 | + } |
| 198 | + |
| 199 | + try { |
| 200 | + // Special case for `mapReduce`, as the only one accepting functions as arguments. |
| 201 | + if (operation === 'mapReduce') { |
| 202 | + const [map, reduce] = args as { name?: string }[]; |
| 203 | + data[signature[0]] = typeof map === 'string' ? map : map.name || '<anonymous>'; |
| 204 | + data[signature[1]] = typeof reduce === 'string' ? reduce : reduce.name || '<anonymous>'; |
| 205 | + } else { |
| 206 | + for (let i = 0; i < signature.length; i++) { |
| 207 | + data[signature[i]] = JSON.stringify(args[i]); |
| 208 | + } |
| 209 | + } |
| 210 | + } catch (_oO) { |
| 211 | + // no-empty |
| 212 | + } |
| 213 | + |
| 214 | + return spanContext; |
| 215 | + } |
| 216 | +} |
0 commit comments