UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

453 lines (401 loc) 13.5 kB
'use strict' const { isMap, isRegExp } = require('node:util').types const DatabasePlugin = require('../../dd-trace/src/plugins/database') class MongodbCorePlugin extends DatabasePlugin { static id = 'mongodb-core' static component = 'mongodb' // avoid using db.name for peer.service since it includes the collection name // should be removed if one day this will be fixed /** * @override */ static peerServicePrecursors = [] /** * @override */ configure (config) { super.configure(config) this.config.heartbeatEnabled = config.heartbeatEnabled ?? this._tracerConfig.DD_TRACE_MONGODB_HEARTBEAT_ENABLED this.config.obfuscateQuery = normaliseObfuscateQuery( config.obfuscateQuery ?? this._tracerConfig.DD_TRACE_MONGODB_OBFUSCATE_QUERY ) } bindStart (ctx) { const { ns, ops, options = {}, name } = ctx // heartbeat commands can be disabled if this.config.heartbeatEnabled is false if (!this.config.heartbeatEnabled && isHeartbeat(ops, this.config)) { return } const query = getQuery(ops, this.config.obfuscateQuery) const resource = truncate(getResource(this, ns, query, name)) const serviceResult = this.serviceName({ pluginConfig: this.config }) const span = this.startSpan(this.operationName(), { service: serviceResult, resource, type: 'mongodb', kind: 'client', meta: { // this is not technically correct since it includes the collection but we changing will break customer stuff 'db.name': ns, 'mongodb.query': query, 'out.host': options.host, 'out.port': options.port, }, }, ctx) const comment = this.injectDbmComment(span, ops.comment, serviceResult.name) if (comment) { ops.comment = comment } return ctx.currentStore } /** * @override */ getPeerService (tags) { let ns = tags['db.name'] if (ns && tags['peer.service'] === undefined) { const dotIndex = ns.indexOf('.') if (dotIndex !== -1) { ns = ns.slice(0, dotIndex) } // the mongo ns is either dbName either dbName.collection. So we keep the first part tags['peer.service'] = ns } return super.getPeerService(tags) } injectDbmComment (span, comment, serviceName) { const dbmTraceComment = this.createDbmComment(span, serviceName) if (!dbmTraceComment) { return comment } if (comment) { // if the command already has a comment, append the dbm trace comment if (typeof comment === 'string') { comment += `,${dbmTraceComment}` } else if (Array.isArray(comment)) { comment.push(dbmTraceComment) } // do nothing if the comment is not a string or an array } else { comment = dbmTraceComment } return comment } } const MAX_DEPTH = 10 const MAX_QUERY_LENGTH = 10_000 function extractQuery (statements) { if (statements.length === 1 && statements[0].q) return statements[0].q const extractedQueries = [] for (let i = 0; i < statements.length; i++) { if (statements[i].q) { extractedQueries.push(statements[i].q) } } return extractedQueries } /** * @param {Record<string, unknown> | unknown[] | undefined} cmd * @param {'none' | 'types' | 'redact'} mode */ function getQuery (cmd, mode) { if (!cmd || (typeof cmd !== 'object' && !Array.isArray(cmd))) return if (Array.isArray(cmd)) return sanitiseAndStringify(extractQuery(cmd), mode) if (cmd.query) return sanitiseAndStringify(cmd.query, mode) if (cmd.filter) return sanitiseAndStringify(cmd.filter, mode) if (cmd.pipeline) return sanitiseAndStringify(cmd.pipeline, mode) if (cmd.deletes) return sanitiseAndStringify(extractQuery(cmd.deletes), mode) if (cmd.updates) return sanitiseAndStringify(extractQuery(cmd.updates), mode) } function getResource (plugin, ns, query, operationName) { let resource = `${operationName} ${ns}` if (plugin.config.queryInResourceName && query) { resource += ` ${query}` } return resource } function truncate (input) { return input.length > MAX_QUERY_LENGTH ? input.slice(0, MAX_QUERY_LENGTH) : input } // Depth doubles as the cycle bound: a cycle pushes past MAX_DEPTH and bails, // after which the slow path catches it via its ancestor stack. /** @param {unknown} input */ function canStringifyDirect (input) { if (input === null || typeof input !== 'object' || ArrayBuffer.isView(input) || input._bsontype !== undefined || isRegExp(input) || isMap(input) || typeof input.toJSON === 'function') { return false } return canStringifyDirectWalk(input, 1) } /** * @param {Record<string, unknown> | unknown[]} value * @param {number} depth */ function canStringifyDirectWalk (value, depth) { if (depth > MAX_DEPTH) return false const children = Array.isArray(value) ? value : Object.values(value) for (const child of children) { if (child === null || typeof child === 'string' || typeof child === 'number' || typeof child === 'boolean') { continue } if (typeof child !== 'object' || ArrayBuffer.isView(child) || child._bsontype !== undefined || isRegExp(child) || isMap(child) || typeof child.toJSON === 'function') { return false } if (!canStringifyDirectWalk(child, depth + 1)) return false } return true } /** * @param {Record<string, unknown> | unknown[]} input * @param {'none' | 'types' | 'redact'} mode */ function sanitiseAndStringify (input, mode) { if (mode === 'none') { if (canStringifyDirect(input)) return JSON.stringify(input) return buildNone(input, []) } if (mode === 'redact') return buildRedact(input, []) return buildTypes(input, []) } const REDACT_LEAF = '"?"' /** * @param {RegExp} value * @returns {string} */ function stringifyRegExp (value) { return `{"$regex":${JSON.stringify(value.source)},"$options":${JSON.stringify(value.flags)}}` } /** * @param {Record<string, unknown> | unknown[]} value * @param {object[]} ancestors * @returns {string | undefined} */ function buildNone (value, ancestors) { // ArrayBuffer views (Buffer, every TypedArray, DataView) and Binary BSON // wrappers redact at the leaf; the walker neither recurses into the bytes // nor invokes any custom conversion. const bsontype = value._bsontype if (ArrayBuffer.isView(value) || bsontype === 'Binary' || ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { return REDACT_LEAF } if (isRegExp(value)) return stringifyRegExp(value) // Mirror JSON.stringify's contract: when `toJSON` is present, walk its // result (wrappers like Timestamp / Decimal128 expand to a small object, // ObjectId / Date flatten to a primitive). if (typeof value.toJSON === 'function') { const json = value.toJSON() if (json === value) return REDACT_LEAF // JSON.stringify keeps a null result as null (an invalid Date's toJSON // returns null); only function / symbol / undefined results drop the key. if (json === null) return 'null' if (typeof json !== 'object') return classifyLeafForNone(json) // A wrapper that exposes binary state through toJSON (Buffer-backed // class with WeakMap state, etc.) returns a TypedArray here. Re-screen // before the per-key walk would expand it element by element. if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF value = json } else if (bsontype !== undefined) { return REDACT_LEAF } // The driver serializes a Map via its entries; mirror that as a document so // the tag matches the wire shape. if (isMap(value)) value = Object.fromEntries(value) ancestors.push(value) let result if (Array.isArray(value)) { result = '[' let sep = '' for (let i = 0; i < value.length; i++) { // JSON.stringify renders unsupported leaves (function, symbol, undefined) as null in arrays. result += sep + (classifyForNone(value[i], ancestors) ?? 'null') sep = ',' } result += ']' } else { result = '{' let sep = '' for (const key of Object.keys(value)) { const childResult = classifyForNone(value[key], ancestors) if (childResult === undefined) continue result += sep + JSON.stringify(key) + ':' + childResult sep = ',' } result += '}' } ancestors.pop() return result } /** * @param {unknown} child * @param {object[]} ancestors * @returns {string | undefined} */ function classifyForNone (child, ancestors) { if (typeof child !== 'object') return classifyLeafForNone(child) if (child === null) return 'null' return buildNone(child, ancestors) } /** * @param {unknown} leaf * @returns {string | undefined} */ function classifyLeafForNone (leaf) { // Implicit `undefined` for function / symbol / undefined matches the // contract callers rely on: JSON.stringify drops those property values // inside objects and writes `null` in arrays. switch (typeof leaf) { case 'string': return JSON.stringify(leaf) case 'number': return Number.isFinite(leaf) ? String(leaf) : 'null' case 'boolean': return leaf ? 'true' : 'false' case 'bigint': return `"${String(leaf)}"` } } /** * @param {Record<string, unknown> | unknown[]} value * @param {object[]} ancestors */ function buildRedact (value, ancestors) { const bsontype = value._bsontype if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { return REDACT_LEAF } // Mirror JSON.stringify: when `toJSON` is present, walk its result (which // wrappers like Timestamp / Decimal128 expand to `{$timestamp: "..."}` etc). // A primitive, null, or self-reference collapses to the sentinel — master's // `value === original` short-circuit. if (typeof value.toJSON === 'function') { const json = value.toJSON() if (typeof json !== 'object' || json === null || json === value) return REDACT_LEAF // Re-screen: toJSON can return a TypedArray or Binary BSON wrapper. if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF value = json } else if (bsontype !== undefined) { return REDACT_LEAF } if (isMap(value)) value = Object.fromEntries(value) ancestors.push(value) let result if (Array.isArray(value)) { result = '[' let sep = '' for (let i = 0; i < value.length; i++) { result += sep + classifyForRedact(value[i], ancestors) sep = ',' } result += ']' } else { result = '{' let sep = '' for (const key of Object.keys(value)) { result += sep + JSON.stringify(key) + ':' + classifyForRedact(value[key], ancestors) sep = ',' } result += '}' } ancestors.pop() return result } /** * @param {unknown} child * @param {object[]} ancestors */ function classifyForRedact (child, ancestors) { if (typeof child !== 'object' || child === null) return REDACT_LEAF return buildRedact(child, ancestors) } const TYPE_OBJECT = '"object"' const TYPE_NULL = '"null"' const TYPE_BY_TYPEOF = { string: '"string"', number: '"number"', boolean: '"boolean"', bigint: '"bigint"', undefined: '"undefined"', } /** * @param {Record<string, unknown> | unknown[]} value * @param {object[]} ancestors */ function buildTypes (value, ancestors) { const bsontype = value._bsontype if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { return TYPE_OBJECT } if (typeof value.toJSON === 'function') { const json = value.toJSON() if (typeof json !== 'object' || json === null || json === value || ArrayBuffer.isView(json) || json._bsontype === 'Binary') { return TYPE_OBJECT } value = json } else if (bsontype !== undefined) { return TYPE_OBJECT } if (isMap(value)) value = Object.fromEntries(value) ancestors.push(value) let result if (Array.isArray(value)) { result = '[' let sep = '' for (let i = 0; i < value.length; i++) { // JSON.stringify renders unsupported leaves (function, symbol) as null in arrays. result += sep + (classifyForTypes(value[i], ancestors) ?? 'null') sep = ',' } result += ']' } else { result = '{' let sep = '' for (const key of Object.keys(value)) { const childResult = classifyForTypes(value[key], ancestors) if (childResult === undefined) continue result += sep + JSON.stringify(key) + ':' + childResult sep = ',' } result += '}' } ancestors.pop() return result } /** * @param {unknown} child * @param {object[]} ancestors */ function classifyForTypes (child, ancestors) { if (typeof child !== 'object') return TYPE_BY_TYPEOF[typeof child] if (child === null) return TYPE_NULL return buildTypes(child, ancestors) } /** @param {unknown} value */ function normaliseObfuscateQuery (value) { if (value === 'types' || value === 'redact') return value return 'none' } function isHeartbeat (ops, config) { // Check if it's a heartbeat command https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.md return ( ops && typeof ops === 'object' && (ops.hello === 1 || ops.helloOk === true || ops.ismaster === 1 || ops.isMaster === 1) ) } module.exports = MongodbCorePlugin