diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index cddcc2529f28..c5d819b1b66b 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -108,10 +108,12 @@ function extractRouteInfo(req: ExpressRequest, options: { path?: boolean; method let path; if (req.baseUrl && req.route) { path = `${req.baseUrl}${req.route.path}`; + } else if (req.route) { + path = `${req.route.path}`; } else if (req.originalUrl || req.url) { path = stripUrlQueryAndFragment(req.originalUrl || req.url || ''); } else { - path = req.route?.path || ''; + path = ''; } let info = ''; diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index eec8553e18c2..e331f69b109f 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,5 +1,5 @@ import { getCurrentHub } from '@sentry/core'; -import { Integration, Span, Transaction } from '@sentry/types'; +import { Integration, Span } from '@sentry/types'; import { fill, logger, parseSemver } from '@sentry/utils'; import * as http from 'http'; import * as https from 'https'; @@ -104,13 +104,13 @@ function _createWrappedRequestMethodFactory( } let span: Span | undefined; - let transaction: Transaction | undefined; + let parentSpan: Span | undefined; const scope = getCurrentHub().getScope(); if (scope && tracingEnabled) { - transaction = scope.getTransaction(); - if (transaction) { - span = transaction.startChild({ + parentSpan = scope.getSpan(); + if (parentSpan) { + span = parentSpan.startChild({ description: `${requestOptions.method || 'GET'} ${requestUrl}`, op: 'request', }); diff --git a/packages/tracing/src/integrations/index.ts b/packages/tracing/src/integrations/index.ts index abe1faf43c27..f4255a0e3937 100644 --- a/packages/tracing/src/integrations/index.ts +++ b/packages/tracing/src/integrations/index.ts @@ -1 +1,2 @@ export { Express } from './express'; +export { Mongo } from './mongo'; diff --git a/packages/tracing/src/integrations/mongo.ts b/packages/tracing/src/integrations/mongo.ts new file mode 100644 index 000000000000..7870631a2948 --- /dev/null +++ b/packages/tracing/src/integrations/mongo.ts @@ -0,0 +1,216 @@ +import { Hub } from '@sentry/hub'; +import { EventProcessor, Integration, SpanContext } from '@sentry/types'; +import { dynamicRequire, fill, logger } from '@sentry/utils'; + +// This allows us to use the same array for both defaults options and the type itself. +// (note `as const` at the end to make it a union of string literal types (i.e. "a" | "b" | ... ) +// and not just a string[]) +type Operation = typeof OPERATIONS[number]; +const OPERATIONS = [ + 'aggregate', // aggregate(pipeline, options, callback) + 'bulkWrite', // bulkWrite(operations, options, callback) + 'countDocuments', // countDocuments(query, options, callback) + 'createIndex', // createIndex(fieldOrSpec, options, callback) + 'createIndexes', // createIndexes(indexSpecs, options, callback) + 'deleteMany', // deleteMany(filter, options, callback) + 'deleteOne', // deleteOne(filter, options, callback) + 'distinct', // distinct(key, query, options, callback) + 'drop', // drop(options, callback) + 'dropIndex', // dropIndex(indexName, options, callback) + 'dropIndexes', // dropIndexes(options, callback) + 'estimatedDocumentCount', // estimatedDocumentCount(options, callback) + 'findOne', // findOne(query, options, callback) + 'findOneAndDelete', // findOneAndDelete(filter, options, callback) + 'findOneAndReplace', // findOneAndReplace(filter, replacement, options, callback) + 'findOneAndUpdate', // findOneAndUpdate(filter, update, options, callback) + 'indexes', // indexes(options, callback) + 'indexExists', // indexExists(indexes, options, callback) + 'indexInformation', // indexInformation(options, callback) + 'initializeOrderedBulkOp', // initializeOrderedBulkOp(options, callback) + 'insertMany', // insertMany(docs, options, callback) + 'insertOne', // insertOne(doc, options, callback) + 'isCapped', // isCapped(options, callback) + 'mapReduce', // mapReduce(map, reduce, options, callback) + 'options', // options(options, callback) + 'parallelCollectionScan', // parallelCollectionScan(options, callback) + 'rename', // rename(newName, options, callback) + 'replaceOne', // replaceOne(filter, doc, options, callback) + 'stats', // stats(options, callback) + 'updateMany', // updateMany(filter, update, options, callback) + 'updateOne', // updateOne(filter, update, options, callback) +] as const; + +// All of the operations above take `options` and `callback` as their final parameters, but some of them +// take additional parameters as well. For those operations, this is a map of +// { : [] }, as a way to know what to call the operation's +// positional arguments when we add them to the span's `data` object later +const OPERATION_SIGNATURES: { + [op in Operation]?: string[]; +} = { + aggregate: ['pipeline'], + bulkWrite: ['operations'], + countDocuments: ['query'], + createIndex: ['fieldOrSpec'], + createIndexes: ['indexSpecs'], + deleteMany: ['filter'], + deleteOne: ['filter'], + distinct: ['key', 'query'], + dropIndex: ['indexName'], + findOne: ['query'], + findOneAndDelete: ['filter'], + findOneAndReplace: ['filter', 'replacement'], + findOneAndUpdate: ['filter', 'update'], + indexExists: ['indexes'], + insertMany: ['docs'], + insertOne: ['doc'], + mapReduce: ['map', 'reduce'], + rename: ['newName'], + replaceOne: ['filter', 'doc'], + updateMany: ['filter', 'update'], + updateOne: ['filter', 'update'], +}; + +interface MongoCollection { + collectionName: string; + dbName: string; + namespace: string; + prototype: { + [operation in Operation]: (...args: unknown[]) => unknown; + }; +} + +interface MongoOptions { + operations?: Operation[]; + describeOperations?: boolean | Operation[]; +} + +/** Tracing integration for mongo package */ +export class Mongo implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Mongo'; + + /** + * @inheritDoc + */ + public name: string = Mongo.id; + + private _operations: Operation[]; + private _describeOperations?: boolean | Operation[]; + + /** + * @inheritDoc + */ + public constructor(options: MongoOptions = {}) { + this._operations = Array.isArray(options.operations) + ? options.operations + : ((OPERATIONS as unknown) as Operation[]); + this._describeOperations = 'describeOperations' in options ? options.describeOperations : true; + } + + /** + * @inheritDoc + */ + public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + let collection: MongoCollection; + + try { + const mongodbModule = dynamicRequire(module, 'mongodb') as { Collection: MongoCollection }; + collection = mongodbModule.Collection; + } catch (e) { + logger.error('Mongo Integration was unable to require `mongodb` package.'); + return; + } + + this._instrumentOperations(collection, this._operations, getCurrentHub); + } + + /** + * Patches original collection methods + */ + private _instrumentOperations(collection: MongoCollection, operations: Operation[], getCurrentHub: () => Hub): void { + operations.forEach((operation: Operation) => this._patchOperation(collection, operation, getCurrentHub)); + } + + /** + * Patches original collection to utilize our tracing functionality + */ + private _patchOperation(collection: MongoCollection, operation: Operation, getCurrentHub: () => Hub): void { + if (!(operation in collection.prototype)) return; + + const getSpanContext = this._getSpanContextFromOperationArguments.bind(this); + + fill(collection.prototype, operation, function(orig: () => void | Promise) { + return function(this: unknown, ...args: unknown[]) { + const lastArg = args[args.length - 1]; + const scope = getCurrentHub().getScope(); + const parentSpan = scope?.getSpan(); + + // Check if the operation was passed a callback. (mapReduce requires a different check, as + // its (non-callback) arguments can also be functions.) + if (typeof lastArg !== 'function' || (operation === 'mapReduce' && args.length === 2)) { + const span = parentSpan?.startChild(getSpanContext(this, operation, args)); + return (orig.call(this, ...args) as Promise).then((res: unknown) => { + span?.finish(); + return res; + }); + } + + const span = parentSpan?.startChild(getSpanContext(this, operation, args.slice(0, -1))); + return orig.call(this, ...args.slice(0, -1), function(err: Error, result: unknown) { + span?.finish(); + lastArg(err, result); + }); + }; + }); + } + + /** + * Form a SpanContext based on the user input to a given operation. + */ + private _getSpanContextFromOperationArguments( + collection: MongoCollection, + operation: Operation, + args: unknown[], + ): SpanContext { + const data: { [key: string]: string } = { + collectionName: collection.collectionName, + dbName: collection.dbName, + namespace: collection.namespace, + }; + const spanContext: SpanContext = { + op: `db`, + description: operation, + data, + }; + + // If the operation takes no arguments besides `options` and `callback`, or if argument + // collection is disabled for this operation, just return early. + const signature = OPERATION_SIGNATURES[operation]; + const shouldDescribe = Array.isArray(this._describeOperations) + ? this._describeOperations.includes(operation) + : this._describeOperations; + + if (!signature || !shouldDescribe) { + return spanContext; + } + + try { + // Special case for `mapReduce`, as the only one accepting functions as arguments. + if (operation === 'mapReduce') { + const [map, reduce] = args as { name?: string }[]; + data[signature[0]] = typeof map === 'string' ? map : map.name || ''; + data[signature[1]] = typeof reduce === 'string' ? reduce : reduce.name || ''; + } else { + for (let i = 0; i < signature.length; i++) { + data[signature[i]] = JSON.stringify(args[i]); + } + } + } catch (_oO) { + // no-empty + } + + return spanContext; + } +}