|
| 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