lib/instrumentation/modules/mongodb.js (170 lines of code) (raw):
/*
* Copyright Elasticsearch B.V. and other contributors where applicable.
* Licensed under the BSD 2-Clause License; you may not use this file except in
* compliance with the BSD 2-Clause License.
*/
'use strict';
const semver = require('semver');
const { OUTCOME_SUCCESS, OUTCOME_FAILURE } = require('../../constants');
const { getDBDestination } = require('../context');
const shimmer = require('../shimmer');
const kListenersAdded = Symbol('kListenersAdded');
// Match expected `<hostname>:<port>`, e.g. "mongo:27017", "::1:27017",
// "127.0.0.1:27017".
const HOSTNAME_PORT_RE = /^(.+):(\d+)$/;
module.exports = (mongodb, agent, { version, enabled }) => {
if (!enabled) return mongodb;
if (!semver.satisfies(version, '>=3.3 <7')) {
agent.logger.debug(
'mongodb version %s not instrumented (mongodb <3.3 is instrumented via mongodb-core)',
version,
);
return mongodb;
}
const ins = agent._instrumentation;
const activeSpans = new Map();
if (mongodb.instrument) {
const listener = mongodb.instrument();
listener.on('started', onStart);
listener.on('succeeded', onSuccess);
listener.on('failed', onFailure);
} else if (mongodb.MongoClient) {
// mongodb 4.0+ removed the instrument() method in favor of
// listeners on the instantiated client objects. There are two mechanisms
// to get a client:
// 1. const client = new mongodb.MongoClient(...)
// 2. const client = await MongoClient.connect(...)
class MongoClientTraced extends mongodb.MongoClient {
constructor() {
// The `command*` events are only emitted if `options.monitorCommands: true`.
const args = Array.prototype.slice.call(arguments);
if (!args[1]) {
args[1] = { monitorCommands: true };
} else if (args[1].monitorCommands !== true) {
args[1] = Object.assign({}, args[1], { monitorCommands: true });
}
super(...args);
this.on('commandStarted', onStart);
this.on('commandSucceeded', onSuccess);
this.on('commandFailed', onFailure);
this[kListenersAdded] = true;
}
}
Object.defineProperty(mongodb, 'MongoClient', {
enumerable: true,
get: function () {
return MongoClientTraced;
},
});
shimmer.wrap(mongodb.MongoClient, 'connect', wrapConnect);
} else {
agent.logger.warn('could not instrument mongodb@%s', version);
}
return mongodb;
// Wrap the MongoClient.connect(url, options?, callback?) static method.
// It calls back with `function (err, client)` or returns a Promise that
// resolves to the client.
// https://github.com/mongodb/node-mongodb-native/blob/v4.2.1/src/mongo_client.ts#L503-L511
//
// From versions >=4.11.0 the method uses `new this` to create the client instance. Hence
// our `MongoClientTraced`'s constructor is used and the command listeners are already
// registered. We should check if the client has already added the listeners to avoid handling
// the same events twice
// NOTE: prefering to use a Symbol over version check since internal implementation may
// change in future versions
// https://github.com/mongodb/node-mongodb-native/blob/v4.11.0/src/mongo_client.ts#L618
function wrapConnect(origConnect) {
return function wrappedConnect(url, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
if (!options.monitorCommands) {
options.monitorCommands = true;
}
if (typeof callback === 'function') {
return origConnect.call(
this,
url,
options,
function wrappedCallback(err, client) {
if (err) {
callback(err);
} else {
if (!client[kListenersAdded]) {
client.on('commandStarted', onStart);
client.on('commandSucceeded', onSuccess);
client.on('commandFailed', onFailure);
client[kListenersAdded] = true;
}
callback(err, client);
}
},
);
} else {
const p = origConnect.call(this, url, options, callback);
p.then((client) => {
if (!client[kListenersAdded]) {
client.on('commandStarted', onStart);
client.on('commandSucceeded', onSuccess);
client.on('commandFailed', onFailure);
client[kListenersAdded] = true;
}
});
return p;
}
};
}
function onStart(event) {
// `event` is a `CommandStartedEvent`
// https://github.com/mongodb/specifications/blob/master/source/command-logging-and-monitoring/command-logging-and-monitoring.rst#command-started-message
// E.g. with mongodb@6.2.0:
// CommandStartedEvent {
// connectionId: '127.0.0.1:27017',
// requestId: 1,
// databaseName: 'test',
// commandName: 'insert',
// command:
// { ... } }
const name = [
event.databaseName,
collectionFor(event),
event.commandName,
].join('.');
const span = ins.createSpan(name, 'db', 'mongodb', event.commandName, {
exitSpan: true,
});
if (span) {
activeSpans.set(event.requestId, span);
// Destination context.
// Per the following code it looks like "<hostname>:<port>" should be
// available via the `address` or `connectionId` field.
// https://github.com/mongodb/node-mongodb-native/blob/dd356f0ede/lib/core/connection/apm.js#L155-L169
const address = event.address || event.connectionId;
let match;
if (
address &&
typeof address === 'string' &&
(match = HOSTNAME_PORT_RE.exec(address))
) {
span._setDestinationContext(getDBDestination(match[1], match[2]));
} else {
agent.logger.trace(
'could not set destination context on mongodb span from address=%j',
address,
);
}
const dbContext = { type: 'mongodb', instance: event.databaseName };
span.setDbContext(dbContext);
}
}
function onSuccess(event) {
// `event` is a `CommandSucceededEvent`
// https://github.com/mongodb/specifications/blob/master/source/command-logging-and-monitoring/command-logging-and-monitoring.rst#command-succeeded-message
// E.g. with mongodb@6.2.0:
// CommandSucceededEvent {
// connectionId: '127.0.0.1:27017',
// requestId: 1,
// duration: 1,
// reply: { ... }
// }
if (!activeSpans.has(event.requestId)) return;
const span = activeSpans.get(event.requestId);
activeSpans.delete(event.requestId);
// From mongodb@3.6.0 and up some commands like `deleteOne` may contain
// error data inside the `reply` property. It makes sense to capture it.
const writeErrors = event?.reply?.writeErrors;
if (writeErrors && writeErrors.length) {
agent.captureError(writeErrors[0].errmsg, {
skipOutcome: true,
parent: span,
});
}
span.setOutcome(OUTCOME_SUCCESS);
span.end(span._timer.start / 1000 + event.duration);
}
function onFailure(event) {
// `event` is a `CommandFailedEvent`
// https://github.com/mongodb/specifications/blob/master/source/command-logging-and-monitoring/command-logging-and-monitoring.rst#command-failed-message
// E.g. with mongodb@6.2.0:
// CommandFailedEvent {
// connectionId: '127.0.0.1:27017',
// requestId: 6,
// commandName: "find",
// duration: 1,
// "failure": { ... }
// }
if (!activeSpans.has(event.requestId)) return;
const span = activeSpans.get(event.requestId);
activeSpans.delete(event.requestId);
span.setOutcome(OUTCOME_FAILURE);
span.end(span._timer.start / 1000 + event.duration);
}
function collectionFor(event) {
// `getMore` command is a special case where the collection name is
// placed in a property named `collection`. The types exported
// do not ensure the property is there so we are defensive on its
// retrieval.
//
// Example of `getMore` payload:
// {
// event: CommandStartedEvent {
// name: 'commandStarted',
// address: '172.20.0.2:27017',
// connectionId: 12,
// requestId: 686,
// databaseName: 'mydatabase',
// commandName: 'getMore',
// command: {
// getMore: new Long('1769182660590360229'),
// collection: 'Interaction',
// ...
// }
// },
// commandName: 'getMore',
// collection: new Long('1769182660590360229')
// }
// ref: https://github.com/elastic/apm-agent-nodejs/issues/3834
if (
event.commandName === 'getMore' &&
typeof event.command.collection === 'string'
) {
return event.command.collection;
}
const collection = event.command[event.commandName];
return typeof collection === 'string' ? collection : '$cmd';
}
};