Skip to content

Commit

Permalink
feat: MongoDB Tracing Support (#3072)
Browse files Browse the repository at this point in the history
* feat: MongoDB Tracing Support

* s/concrete/literal

* add comment explaining operation signature map

* add missing operation

* copy pasta

* streamline comment

* clarify comment

* ref: Use getSpan vs. getTransaction

* fix: Express name and span op

Co-authored-by: Katie Byers <katie.byers@sentry.io>
Co-authored-by: Daniel Griesser <daniel.griesser.86@gmail.com>
  • Loading branch information
3 people authored Dec 4, 2020
1 parent 22ecbcd commit f7fc733
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 6 deletions.
4 changes: 3 additions & 1 deletion packages/node/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
10 changes: 5 additions & 5 deletions packages/node/src/integrations/http.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
});
Expand Down
1 change: 1 addition & 0 deletions packages/tracing/src/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Express } from './express';
export { Mongo } from './mongo';
216 changes: 216 additions & 0 deletions packages/tracing/src/integrations/mongo.ts
Original file line number Diff line number Diff line change
@@ -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
// { <operation name>: [<names of additional parameters>] }, 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<unknown>) {
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<unknown>).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 || '<anonymous>';
data[signature[1]] = typeof reduce === 'string' ? reduce : reduce.name || '<anonymous>';
} else {
for (let i = 0; i < signature.length; i++) {
data[signature[i]] = JSON.stringify(args[i]);
}
}
} catch (_oO) {
// no-empty
}

return spanContext;
}
}

0 comments on commit f7fc733

Please sign in to comment.