newrelic
Version:
New Relic agent
340 lines (301 loc) • 10.8 kB
JavaScript
/*
* Copyright 2024 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
'use strict'
const semver = require('semver')
const { SpanKind } = require('@opentelemetry/api')
const srcJson = require('./transformation-rules/index.js')()
/**
* An OtelRuleMatcher object defines how the rules engine will decide if a
* rule matches an OTEL span. See {@link Rule#matches} to learn how the
* matcher properties are evaluated and in what priority.
*
* @typedef {object} OtelRuleMatcher
* @property {string[]} required_span_kinds List of OTEL span kinds that
* this rule will match. Possible values are "server", "client", and "internal".
* @property {string[]} required_attribute_keys List of OTEL span
* attributes that must be present for the rule to match. Each value is a dot
* separated path.
* @property {object} [attribute_conditions] List of OTEL span attributes that
* must contain a value in the array. For example, if the
* `required_attribute_keys` list contains `"db.system"`, this object may
* contain a field named `db.system` with a value like `["mongodb"]` to
* ensure that the rule only matches when the database system is defined to
* be "mongodb" by the OTEL instrumentation.
* @property {string} [scope_name] Indicates that the span's
* `instrumentationScope.name` must be set to the specified value.
* https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_sdk-node.core.InstrumentationScope.html
* @property {string} [scope_version] Indicates that the span's
* `instrumentationScope.version` must satisfy the provided version range.
* For example, the value in the rule may be `^1.0.0` and the value in the
* span may be `1.5.0`.
* https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_sdk-node.core.InstrumentationScope.html
*/
/**
* Mapping rules are stored, and provided to the agent, as JSON. We call these
* representations `SerializedOtelMappingRule`.
*
*
* @typedef {object} SerializedOtelMappingRule
* @property {string} name The name of the rule. This will be used to detect
* @property {string} type The type of the rule. This will be used to detect the type of segment/transaction to create.
* @property {OtelRuleMatcher} matcher Defines the required properties of the
* OTEL span that are needed in order for the rule to match.
* @property {Array} attributes The attributes to map to a given target(segment/transaction/trace).
* @property {object} transaction The transformation to apply to the transaction.
* @property {object} segment The transformation to apply to the segment. Keys
* in this object map to {@link TraceSegment} attributes. Values are either
* a string pointing to the OTEL span attribute to use for the value, or an
* object as described in `documentation/otel/otel-transformation-rules.md`.
*/
/**
* Represents an OTEL to New Relic span mapping rule.
*/
class Rule {
static OTEL_SPAN_KIND_SERVER = 'server'
static OTEL_SPAN_KIND_CLIENT = 'client'
static OTEL_SPAN_KIND_PRODUCER = 'producer'
static OTEL_SPAN_KIND_CONSUMER = 'consumer'
static OTEL_SPAN_KIND_INTERNAL = 'internal'
#name
#spanKinds
#requiredAttributes
#requiredConditions
#type
#attributes
#segmentTransformation
#txTransformation
#scope = { name: undefined, version: undefined }
/**
* @param {SerializedOtelMappingRule} input A serialized rule to parse.
*/
constructor(input) {
// See https://opentelemetry.io/docs/specs/otel/trace/api/#spankind for
// information about how OTEL classifies server vs client.
if (Object.hasOwn(input.matcher, 'required_span_kinds') === false) {
throw Error('we only support "span" matching rules')
}
this.#name = input.name
this.#type = input.type
this.#attributes = input.attributes ?? []
this.#txTransformation = input.transaction ?? {}
this.#segmentTransformation = input.segment ?? {}
this.#spanKinds = input?.matcher?.required_span_kinds?.map((v) => v.toLowerCase()) ?? []
this.#requiredAttributes = input?.matcher?.required_attribute_keys ?? []
this.#requiredConditions = input?.matcher?.attribute_conditions ?? {}
this.#scope.name = input?.matcher?.scope_name
this.#scope.version = input?.matcher?.scope_version
}
get name() {
return this.#name
}
get type() {
return this.#type
}
get attributes() {
return this.#attributes
}
set txTransformation(value) {
this.#txTransformation = value
}
get txTransformation() {
return this.#txTransformation
}
get segmentTransformation() {
return this.#segmentTransformation
}
get isClientRule() {
return this.#spanKinds.includes(Rule.OTEL_SPAN_KIND_CLIENT)
}
get isInternalRule() {
return this.#spanKinds.includes('internal')
}
get isProducerRule() {
return this.#spanKinds.includes(Rule.OTEL_SPAN_KIND_PRODUCER)
}
get isConsumerRule() {
return this.#spanKinds.includes(Rule.OTEL_SPAN_KIND_CONSUMER)
}
get isServerRule() {
return this.#spanKinds.includes(Rule.OTEL_SPAN_KIND_SERVER)
}
/**
* Determines if the given span satisfies this rule's requirements.
*
* @param {object} span An OTEL span instance.
*
* @returns {boolean} `true` if the span satisfies the rule.
*/
matches(span) {
let result = false
// If the rule has defined either scope name or version requirements,
// evaluate them first so that we avoid iterating attributes in the case
// of a rule mismatch.
const { name: scopeName, version: scopeVersion } = span.instrumentationScope || {}
if (this.#scope.name && scopeName !== this.#scope.name) {
return result
}
if (
(this.#scope.version && scopeVersion) &&
semver.satisfies(scopeVersion, this.#scope.version) === false
) {
return result
}
let attrCount = 0
let attrConditions = 0
for (const attr of this.#requiredAttributes) {
if (Object.hasOwn(span.attributes, attr) === true) {
attrCount += 1
}
}
for (const [key, value] of Object.entries(this.#requiredConditions)) {
if (
(Array.isArray(value) && value.includes(span.attributes[key])) ||
span.attributes[key] === value
) {
attrConditions += 1
}
}
if (
attrCount === this.#requiredAttributes.length &&
attrConditions === Object.keys(this.#requiredConditions).length
) {
result = true
}
return result
}
}
class RulesEngine {
#serverRules = new Map()
#consumerRules = new Map()
#producerRules = new Map()
#fallbackServerRules = new Map()
#fallbackConsumerRules = new Map()
#clientRules = new Map()
#fallbackClientRules = new Map()
#fallbackInternalRules = new Map()
#fallbackProducerRules = new Map()
constructor({ rules = srcJson } = {}) {
for (const inputRule of rules) {
const rule = new Rule(inputRule)
if (/fallback/i.test(rule.name) === true) {
if (rule.isServerRule === true) {
this.#fallbackServerRules.set(rule.name, rule)
} else if (rule.isConsumerRule === true) {
this.#fallbackConsumerRules.set(rule.name, rule)
} else if (rule.isClientRule === true) {
this.#fallbackClientRules.set(rule.name, rule)
} else if (rule.isProducerRule === true) {
this.#fallbackProducerRules.set(rule.name, rule)
} else if (rule.isInternalRule === true) {
this.#fallbackInternalRules.set(rule.name, rule)
}
continue
}
if (rule.isServerRule === true) {
this.#serverRules.set(rule.name, rule)
} else if (rule.isConsumerRule === true) {
this.#consumerRules.set(rule.name, rule)
} else if (rule.isClientRule === true) {
this.#clientRules.set(rule.name, rule)
} else if (rule.isProducerRule === true) {
this.#producerRules.set(rule.name, rule)
}
}
}
/**
* Determines if the span matches any known rules. If the span matches, then
* the matching rule will be returned.
*
* @param {object} otelSpan The span to test.
*
* @returns {Rule|undefined} The matching rule, or `undefined` if no rules match.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
test(otelSpan) {
let result
// eslint-disable-next-line sonarjs/no-labels, no-labels
detector: switch (otelSpan.kind) {
case SpanKind.SERVER: {
for (const rule of this.#serverRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
// eslint-disable-next-line no-labels
break detector
}
}
for (const rule of this.#fallbackServerRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
break
}
}
break
}
case SpanKind.CONSUMER: {
for (const rule of this.#consumerRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
// eslint-disable-next-line no-labels
break detector
}
}
for (const rule of this.#fallbackConsumerRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
break
}
}
break
}
case SpanKind.CLIENT: {
for (const rule of this.#clientRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
// eslint-disable-next-line no-labels
break detector
}
}
for (const rule of this.#fallbackClientRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
break
}
}
break
}
// there currently are no producer rules, just fallback
// if we add new rules they will have to be wired up
case SpanKind.PRODUCER: {
for (const rule of this.#producerRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
// eslint-disable-next-line no-labels
break detector
}
}
for (const rule of this.#fallbackProducerRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
break
}
}
break
}
// there currently are no internal rules, just fallback
// if we add new rules they will have to be wired up
case SpanKind.INTERNAL: {
for (const rule of this.#fallbackInternalRules.values()) {
if (rule.matches(otelSpan) === true) {
result = rule
break
}
}
break
}
}
return result
}
}
module.exports = { RulesEngine, Rule }