diff --git a/lib/db/query-parsers/elasticsearch.js b/lib/db/query-parsers/elasticsearch.js new file mode 100644 index 0000000000..2a4427a9b6 --- /dev/null +++ b/lib/db/query-parsers/elasticsearch.js @@ -0,0 +1,102 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' +const logger = require('../../logger').child({ component: 'elasticsearch_query_parser' }) +const { isNotEmpty } = require('../../util/objects') + +/** + * Parses the parameters sent to elasticsearch for collection, + * method, and query + * + * @param {object} params Query object received by the datashim. + * Required properties: path {string}, method {string}. + * Optional properties: querystring {string}, body {object}, and + * bulkBody {object} + * @returns {object} consisting of collection {string}, operation {string}, + * and query {string} + */ +function queryParser(params) { + params = JSON.parse(params) + const { collection, operation } = parsePath(params.path, params.method) + + // the substance of the query may be in querystring or in body. + let queryParam = {} + if (isNotEmpty(params.querystring)) { + queryParam = params.querystring + } + // let body or bulkBody override querystring, as some requests have both + if (isNotEmpty(params.body)) { + queryParam = params.body + } else if (Array.isArray(params.bulkBody) && params.bulkBody.length) { + queryParam = params.bulkBody + } + // The helper interface provides a simpler API: + + const query = JSON.stringify(queryParam) + + return { + collection, + operation, + query + } +} + +/** + * Convenience function for parsing the params.path sent to the queryParser + * for normalized collection and operation + * + * @param {string} pathString params.path supplied to the query parser + * @param {string} method http method called by @elastic/elasticsearch + * @returns {object} consisting of collection {string} and operation {string} + */ +function parsePath(pathString, method) { + let collection + let operation + const defaultCollection = 'any' + const actions = { + GET: 'get', + PUT: 'create', + POST: 'create', + DELETE: 'delete', + HEAD: 'exists' + } + const suffix = actions[method] + + try { + const path = pathString.split('/') + if (method === 'PUT' && path.length === 2) { + collection = path?.[1] || defaultCollection + operation = `index.create` + return { collection, operation } + } + path.forEach((segment, idx) => { + const prev = idx - 1 + let opname + if (segment === '_search') { + collection = path?.[prev] || defaultCollection + operation = `search` + } else if (segment[0] === '_') { + opname = segment.substring(1) + collection = path?.[prev] || defaultCollection + operation = `${opname}.${suffix}` + } + }) + if (!operation && !collection) { + // likely creating an index--no underscore segments + collection = path?.[1] || defaultCollection + operation = `index.${suffix}` + } + } catch (e) { + logger.warn('Failed to parse path for operation and collection. Using defaults') + logger.warn(e) + collection = defaultCollection + operation = 'unknown' + } + + return { collection, operation } +} + +module.exports = { queryParser, parsePath } diff --git a/lib/db/query-parsers/mongodb.js b/lib/db/query-parsers/mongodb.js new file mode 100644 index 0000000000..c07cbed872 --- /dev/null +++ b/lib/db/query-parsers/mongodb.js @@ -0,0 +1,29 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +/** + * parser used to grab the collection and operation + * from a running query + * + * @param {object} operation mongodb operation + * @returns {object} { operation, collection } parsed operation and collection + */ +function queryParser(operation) { + let collection = this.collectionName || 'unknown' + + // cursor methods have collection on namespace.collection + if (this?.namespace?.collection) { + collection = this.namespace.collection + // (un)ordered bulk operations have collection on different key + } else if (this?.s?.collection?.collectionName) { + collection = this.s.collection.collectionName + } + + return { operation, collection } +} + +module.exports = queryParser diff --git a/lib/instrumentation/@elastic/elasticsearch.js b/lib/instrumentation/@elastic/elasticsearch.js index bc88e2a417..15fe7f1699 100644 --- a/lib/instrumentation/@elastic/elasticsearch.js +++ b/lib/instrumentation/@elastic/elasticsearch.js @@ -7,8 +7,7 @@ const { QuerySpec } = require('../../shim/specs') const semver = require('semver') -const logger = require('../../logger').child({ component: 'ElasticSearch' }) -const { isNotEmpty } = require('../../util/objects') +const { queryParser } = require('../../db/query-parsers/elasticsearch') /** * Instruments the `@elastic/elasticsearch` module. This function is @@ -46,98 +45,6 @@ module.exports = function initialize(_agent, elastic, _moduleName, shim) { }) } -/** - * Parses the parameters sent to elasticsearch for collection, - * method, and query - * - * @param {object} params Query object received by the datashim. - * Required properties: path {string}, method {string}. - * Optional properties: querystring {string}, body {object}, and - * bulkBody {object} - * @returns {object} consisting of collection {string}, operation {string}, - * and query {string} - */ -function queryParser(params) { - params = JSON.parse(params) - const { collection, operation } = parsePath(params.path, params.method) - - // the substance of the query may be in querystring or in body. - let queryParam = {} - if (isNotEmpty(params.querystring)) { - queryParam = params.querystring - } - // let body or bulkBody override querystring, as some requests have both - if (isNotEmpty(params.body)) { - queryParam = params.body - } else if (Array.isArray(params.bulkBody) && params.bulkBody.length) { - queryParam = params.bulkBody - } - // The helper interface provides a simpler API: - - const query = JSON.stringify(queryParam) - - return { - collection, - operation, - query - } -} - -/** - * Convenience function for parsing the params.path sent to the queryParser - * for normalized collection and operation - * - * @param {string} pathString params.path supplied to the query parser - * @param {string} method http method called by @elastic/elasticsearch - * @returns {object} consisting of collection {string} and operation {string} - */ -function parsePath(pathString, method) { - let collection - let operation - const defaultCollection = 'any' - const actions = { - GET: 'get', - PUT: 'create', - POST: 'create', - DELETE: 'delete', - HEAD: 'exists' - } - const suffix = actions[method] - - try { - const path = pathString.split('/') - if (method === 'PUT' && path.length === 2) { - collection = path?.[1] || defaultCollection - operation = `index.create` - return { collection, operation } - } - path.forEach((segment, idx) => { - const prev = idx - 1 - let opname - if (segment === '_search') { - collection = path?.[prev] || defaultCollection - operation = `search` - } else if (segment[0] === '_') { - opname = segment.substring(1) - collection = path?.[prev] || defaultCollection - operation = `${opname}.${suffix}` - } - }) - if (!operation && !collection) { - // likely creating an index--no underscore segments - collection = path?.[1] || defaultCollection - operation = `index.${suffix}` - } - } catch (e) { - logger.warn('Failed to parse path for operation and collection. Using defaults') - logger.warn(e) - collection = defaultCollection - operation = 'unknown' - } - - return { collection, operation } -} - /** * Convenience function for deriving connection information from * elasticsearch @@ -152,6 +59,4 @@ function getConnection(shim) { return shim.captureInstanceAttributes(host[0], port) } -module.exports.queryParser = queryParser -module.exports.parsePath = parsePath module.exports.getConnection = getConnection diff --git a/lib/instrumentation/mongodb/v4-mongo.js b/lib/instrumentation/mongodb/v4-mongo.js index a36c44fcde..0148674531 100644 --- a/lib/instrumentation/mongodb/v4-mongo.js +++ b/lib/instrumentation/mongodb/v4-mongo.js @@ -13,27 +13,7 @@ const { instrumentDb, parseAddress } = require('./common') - -/** - * parser used to grab the collection and operation - * from a running query - * - * @param {object} operation mongodb operation - * @returns {object} { operation, collection } parsed operation and collection - */ -function queryParser(operation) { - let collection = this.collectionName || 'unknown' - - // cursor methods have collection on namespace.collection - if (this?.namespace?.collection) { - collection = this.namespace.collection - // (un)ordered bulk operations have collection on different key - } else if (this?.s?.collection?.collectionName) { - collection = this.s.collection.collectionName - } - - return { operation, collection } -} +const queryParser = require('../../db/query-parsers/mongodb') /** * `commandStarted` handler used to diff --git a/lib/otel/segment-synthesis.js b/lib/otel/segment-synthesis.js index 7441c53b09..b355074af5 100644 --- a/lib/otel/segment-synthesis.js +++ b/lib/otel/segment-synthesis.js @@ -7,7 +7,12 @@ const { RulesEngine } = require('./rules') const defaultLogger = require('../logger').child({ component: 'segment-synthesizer' }) const NAMES = require('../metrics/names') -const { SEMATTRS_HTTP_HOST } = require('@opentelemetry/semantic-conventions') +const { + SEMATTRS_HTTP_HOST, + SEMATTRS_DB_SYSTEM, + SEMATTRS_DB_SQL_TABLE, + SEMATTRS_DB_OPERATION +} = require('@opentelemetry/semantic-conventions') class SegmentSynthesizer { constructor(agent, { logger = defaultLogger } = {}) { @@ -27,9 +32,12 @@ class SegmentSynthesizer { return } - if (rule?.type === 'external') { + if (rule.type === 'external') { return this.createExternalSegment(otelSpan) + } else if (rule.type === 'db') { + return this.createDatabaseSegment(otelSpan) } + this.logger.debug('Found type: %s, no synthesize rule currently built', rule.type) } @@ -45,6 +53,20 @@ class SegmentSynthesizer { transaction: context.transaction }) } + + createDatabaseSegment(otelSpan) { + const context = this.agent.tracer.getContext() + const system = otelSpan.attributes[SEMATTRS_DB_SYSTEM] || 'Unknown' + // TODO: looks like most otel instrumentation does not set this, instead provides the query + // we have to parse this + const table = otelSpan.attributes[SEMATTRS_DB_SQL_TABLE] || 'Unknown' + const operation = otelSpan.attributes[SEMATTRS_DB_OPERATION] || otelSpan.name || 'Unknown' + return this.agent.tracer.createSegment({ + name: `Datastore/statement/${system}/${table}/${operation}`, + parent: context.segment, + transaction: context.transaction + }) + } } module.exports = SegmentSynthesizer diff --git a/test/unit/instrumentation/elasticsearch.test.js b/test/unit/db/query-parsers/elasticsearch.test.js similarity index 97% rename from test/unit/instrumentation/elasticsearch.test.js rename to test/unit/db/query-parsers/elasticsearch.test.js index 02ed7d6bda..d91ac7a0d6 100644 --- a/test/unit/instrumentation/elasticsearch.test.js +++ b/test/unit/db/query-parsers/elasticsearch.test.js @@ -7,7 +7,7 @@ const test = require('node:test') const assert = require('node:assert') -const { parsePath, queryParser } = require('../../../lib/instrumentation/@elastic/elasticsearch') +const { parsePath, queryParser } = require('../../../../lib/db/query-parsers/elasticsearch') const methods = [ { name: 'GET', expected: 'get' }, { name: 'PUT', expected: 'create' },