Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added instrumentation for @opensearch-projects/opensearch v2.1.0+ #2850

Merged
merged 3 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ services:
interval: 30s
timeout: 10s
retries: 5

opensearch:
container_name: nr_node_opensearch
image: opensearchproject/opensearch:2.1.0
environment:
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
# Disable password
- "DISABLE_SECURITY_PLUGIN=true"
# Set cluster to single node
- "discovery.type=single-node"
# Disable high watermarks, used in CI as the runner is constrained on disk space
- "cluster.routing.allocation.disk.threshold_enabled=false"
- "network.host=_site_"
- "transport.host=127.0.0.1"
- "http.host=0.0.0.0"
ports:
- "9201:9200"
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:9200"]
interval: 30s
timeout: 10s
retries: 5

# Kafka setup based on the e2e tests in node-rdkafka. Needs both the
# `zookeeper` and `kafka` services.
Expand Down
161 changes: 161 additions & 0 deletions lib/instrumentation/@opensearch-project/opensearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright 2024 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const { QuerySpec } = require('../../shim/specs')
const semver = require('semver')
const logger = require('../../logger').child({ component: 'OpenSearch' })
const { isNotEmpty } = require('../../util/objects')

/**
* Instruments the `@opensearch-project/opensearch` module. This function is
* passed to `onRequire` when instantiating instrumentation.
*
* @param {object} _agent New Relic agent
* @param {object} opensearch resolved module
* @param {string} _moduleName string representation of require/import path
* @param {object} shim New Relic shim
* @returns {void}
*/
module.exports = function initialize(_agent, opensearch, _moduleName, shim) {
const pkgVersion = shim.pkgVersion
if (semver.lt(pkgVersion, '2.1.0')) {
shim &&
shim.logger.debug(
`Opensearch support is for versions 2.1.0 and above. Not instrumenting ${pkgVersion}.`
)
return
}

shim.setDatastore(shim.OPENSEARCH)
shim.setParser(queryParser)

shim.recordQuery(
opensearch.Transport.prototype,
'request',
function wrapQuery(shim, _, __, args) {
const ctx = this
return new QuerySpec({
query: JSON.stringify(args?.[0]),
promise: true,
opaque: true,
inContext: function inContext() {
getConnection.call(ctx, shim)
}
})
}
)
}

/**
* Parses the parameters sent to opensearch 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 @opensearch-project/opensearch
* @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
* opensearch
*
* @param {object} shim The New Relic datastore-shim
* @returns {Function} captureInstanceAttributes method of shim
*/
function getConnection(shim) {
const connectionPool = this.connectionPool.connections[0]
const host = connectionPool.url.host.split(':')
const port = connectionPool.url.port || host?.[1]
return shim.captureInstanceAttributes(host[0], port)
}

module.exports.queryParser = queryParser
module.exports.parsePath = parsePath
module.exports.getConnection = getConnection
1 change: 1 addition & 0 deletions lib/instrumentations.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const InstrumentationDescriptor = require('./instrumentation-descriptor')
module.exports = function instrumentations() {
return {
'@elastic/elasticsearch': { type: InstrumentationDescriptor.TYPE_DATASTORE },
'@opensearch-project/opensearch': { type: InstrumentationDescriptor.TYPE_DATASTORE },
'@grpc/grpc-js': { module: './instrumentation/grpc-js' },
'@hapi/hapi': { type: InstrumentationDescriptor.TYPE_WEB_FRAMEWORK },
'@hapi/vision': { type: InstrumentationDescriptor.TYPE_WEB_FRAMEWORK },
Expand Down
1 change: 1 addition & 0 deletions lib/shim/datastore-shim.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const DATASTORE_NAMES = {
MONGODB: 'MongoDB',
MYSQL: 'MySQL',
NEPTUNE: 'Neptune',
OPENSEARCH: 'OpenSearch',
POSTGRES: 'Postgres',
REDIS: 'Redis',
PRISMA: 'Prisma'
Expand Down
2 changes: 2 additions & 0 deletions test/lib/params.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ module.exports = {

elastic_host: process.env.NR_NODE_TEST_ELASTIC_HOST || 'localhost',
elastic_port: process.env.NR_NODE_TEST_ELASTIC_PORT || 9200,
opensearch_host: process.env.NR_NODE_TEST_OPENSEARCH_HOST || 'localhost',
opensearch_port: process.env.NR_NODE_TEST_OPENSEARCH_PORT || 9201,

postgres_host: process.env.NR_NODE_TEST_POSTGRES_HOST || 'localhost',
postgres_port: process.env.NR_NODE_TEST_POSTGRES_PORT || 5432,
Expand Down
154 changes: 154 additions & 0 deletions test/unit/instrumentation/opensearch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2023 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const test = require('node:test')
const assert = require('node:assert')
const {
parsePath,
queryParser
} = require('../../../lib/instrumentation/@opensearch-project/opensearch')
const instrumentation = require('../../../lib/instrumentation/@opensearch-project/opensearch')
const methods = [
{ name: 'GET', expected: 'get' },
{ name: 'PUT', expected: 'create' },
{ name: 'POST', expected: 'create' },
{ name: 'DELETE', expected: 'delete' },
{ name: 'HEAD', expected: 'exists' }
]

test('should log warning if version is not supported', async () => {
const shim = {
pkgVersion: '2.0.0',
logger: {
debug(msg) {
assert.equal(
msg,
'Opensearch support is for versions 2.1.0 and above. Not instrumenting 2.0.0.'
)
}
}
}
instrumentation({}, {}, '@opensearch-project/opensearch', shim)
})
test('parsePath should behave as expected', async (t) => {
await t.test('indices', async function () {
const path = '/indexName'
methods.forEach((m) => {
const { collection, operation } = parsePath(path, m.name)
const expectedOp = `index.${m.expected}`
assert.equal(collection, 'indexName', `index should be 'indexName'`)
assert.equal(operation, expectedOp, 'operation should include index and method')
})
})
await t.test('search of one index', async function () {
const path = '/indexName/_search'
methods.forEach((m) => {
const { collection, operation } = parsePath(path, m.name)
const expectedOp = `search`
assert.equal(collection, 'indexName', `index should be 'indexName'`)
assert.equal(operation, expectedOp, `operation should be 'search'`)
})
})
await t.test('search of all indices', async function () {
const path = '/_search/'
methods.forEach((m) => {
if (m.name === 'PUT') {
// skip PUT
return
}
const { collection, operation } = parsePath(path, m.name)
const expectedOp = `search`
assert.equal(collection, 'any', 'index should be `any`')
assert.equal(operation, expectedOp, `operation should match ${expectedOp}`)
})
})
await t.test('doc', async function () {
const path = '/indexName/_doc/testKey'
methods.forEach((m) => {
const { collection, operation } = parsePath(path, m.name)
const expectedOp = `doc.${m.expected}`
assert.equal(collection, 'indexName', `index should be 'indexName'`)
assert.equal(operation, expectedOp, `operation should match ${expectedOp}`)
})
})
await t.test('path is /', async function () {
const path = '/'
methods.forEach((m) => {
const { collection, operation } = parsePath(path, m.name)
const expectedOp = `index.${m.expected}`
assert.equal(collection, 'any', 'index should be `any`')
assert.equal(operation, expectedOp, `operation should match ${expectedOp}`)
})
})
await t.test(
'should provide sensible defaults when path is {} and parser encounters an error',
function () {
const path = {}
methods.forEach((m) => {
const { collection, operation } = parsePath(path, m.name)
const expectedOp = `unknown`
assert.equal(collection, 'any', 'index should be `any`')
assert.equal(operation, expectedOp, `operation should match '${expectedOp}'`)
})
}
)
})

test('queryParser should behave as expected', async (t) => {
await t.test('given a querystring, it should use that for query', () => {
const params = JSON.stringify({
path: '/_search',
method: 'GET',
querystring: { q: 'searchterm' }
})
const expected = {
collection: 'any',
operation: 'search',
query: JSON.stringify({ q: 'searchterm' })
}
const parseParams = queryParser(params)
assert.deepEqual(parseParams, expected, 'queryParser should handle query strings')
})
await t.test('given a body, it should use that for query', () => {
const params = JSON.stringify({
path: '/_search',
method: 'POST',
body: { match: { body: 'document' } }
})
const expected = {
collection: 'any',
operation: 'search',
query: JSON.stringify({ match: { body: 'document' } })
}
const parseParams = queryParser(params)
assert.deepEqual(parseParams, expected, 'queryParser should handle query body')
})
await t.test('given a bulkBody, it should use that for query', () => {
const params = JSON.stringify({
path: '/_msearch',
method: 'POST',
bulkBody: [
{}, // cross-index searches have can have an empty metadata section
{ query: { match: { body: 'sixth' } } },
{},
{ query: { match: { body: 'bulk' } } }
]
})
const expected = {
collection: 'any',
operation: 'msearch.create',
query: JSON.stringify([
{}, // cross-index searches have can have an empty metadata section
{ query: { match: { body: 'sixth' } } },
{},
{ query: { match: { body: 'bulk' } } }
])
}
const parseParams = queryParser(params)
assert.deepEqual(parseParams, expected, 'queryParser should handle query body')
})
})
21 changes: 21 additions & 0 deletions test/versioned/opensearch/newrelic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2020 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

exports.config = {
app_name: ['opensearch test'],
license_key: 'license key here',
utilization: {
detect_aws: false,
detect_pcf: false,
detect_azure: false,
detect_gcp: false,
detect_docker: false
},
logging: {
enabled: true
}
}
Loading
Loading