Skip to content

Commit

Permalink
feat: Added segment synthesis for db client otel spans to db trace (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
bizob2828 committed Dec 9, 2024
1 parent 20514f3 commit c01d724
Show file tree
Hide file tree
Showing 12 changed files with 413 additions and 153 deletions.
102 changes: 102 additions & 0 deletions lib/db/query-parsers/elasticsearch.js
Original file line number Diff line number Diff line change
@@ -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 }
29 changes: 29 additions & 0 deletions lib/db/query-parsers/mongodb.js
Original file line number Diff line number Diff line change
@@ -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
97 changes: 1 addition & 96 deletions lib/instrumentation/@elastic/elasticsearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
22 changes: 1 addition & 21 deletions lib/instrumentation/mongodb/v4-mongo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/otel/rules.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[
{
"name": "OtelHttpServer1_23",
"type": "server",
"matcher": {
"required_metric_names": [
"http.server.request.duration"
Expand Down Expand Up @@ -41,6 +42,7 @@
},
{
"name": "OtelHttpServer1_20",
"type": "server",
"matcher": {
"required_metric_names": [
"http.server.duration"
Expand Down Expand Up @@ -81,6 +83,7 @@
},
{
"name": "OtelRpcServer1_20",
"type": "server",
"matcher": {
"required_metric_names": [
"rpc.server.duration"
Expand Down Expand Up @@ -121,6 +124,7 @@
},
{
"name": "FallbackServer",
"type": "server",
"matcher": {
"required_metric_names": [
"rpc.server.duration",
Expand Down
66 changes: 63 additions & 3 deletions lib/otel/segment-synthesis.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
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_MONGODB_COLLECTION,
SEMATTRS_DB_SYSTEM,
SEMATTRS_DB_SQL_TABLE,
SEMATTRS_DB_OPERATION,
SEMATTRS_DB_STATEMENT,
DbSystemValues
} = require('@opentelemetry/semantic-conventions')
const parseSql = require('../db/query-parsers/sql')

class SegmentSynthesizer {
constructor(agent, { logger = defaultLogger } = {}) {
Expand All @@ -27,10 +36,13 @@ 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)

this.logger.debug('Found type: %s, no synthesis rule currently built', rule.type)
}

// TODO: should we move these to somewhere else and use in the places
Expand All @@ -45,6 +57,54 @@ class SegmentSynthesizer {
transaction: context.transaction
})
}

parseStatement(otelSpan, system) {
let table = otelSpan.attributes[SEMATTRS_DB_SQL_TABLE]
let operation = otelSpan.attributes[SEMATTRS_DB_OPERATION]
const statement = otelSpan.attributes[SEMATTRS_DB_STATEMENT]
if (statement && !(table || operation)) {
const parsed = parseSql({ sql: statement })
if (parsed.operation && !operation) {
operation = parsed.operation
}

if (parsed.collection && !table) {
table = parsed.collection
}
}
if (system === DbSystemValues.MONGODB) {
table = otelSpan.attributes[SEMATTRS_DB_MONGODB_COLLECTION]
}

if (system === DbSystemValues.REDIS && statement) {
;[operation] = statement.split(' ')
}

table = table || 'Unknown'
operation = operation || 'Unknown'

return { operation, table }
}

// TODO: This probably has some holes
// I did analysis and tried to apply the best logic
// to extract table/operation
createDatabaseSegment(otelSpan) {
const context = this.agent.tracer.getContext()
const system = otelSpan.attributes[SEMATTRS_DB_SYSTEM]
const { operation, table } = this.parseStatement(otelSpan, system)

let name = `Datastore/statement/${system}/${table}/${operation}`
// All segment name shapes are same except redis/memcached
if (system === DbSystemValues.REDIS || system === DbSystemValues.MEMCACHED) {
name = `Datastore/operation/${system}/${operation}`
}
return this.agent.tracer.createSegment({
name,
parent: context.segment,
transaction: context.transaction
})
}
}

module.exports = SegmentSynthesizer
Loading

0 comments on commit c01d724

Please sign in to comment.