diff --git a/lib/otel/constants.js b/lib/otel/constants.js index 0b8f0b2ce2..e5664ac6e4 100644 --- a/lib/otel/constants.js +++ b/lib/otel/constants.js @@ -166,6 +166,15 @@ module.exports = { */ ATTR_RPC_SYSTEM: 'rpc.system', + /** + * Server domain name, IP address, or Unix domain socket. + * + * @example example.com + * @example 10.1.2.80 + * @example /tmp/my.sock + */ + ATTR_SERVER_ADDRESS: 'server.address', + /** * Logical name of the local service being instrumented. */ @@ -178,6 +187,13 @@ module.exports = { */ ATTR_URL_PATH: 'url.path', + /** + * The scheme value for the URL. + * + * @example https + */ + ATTR_URL_SCHEME: 'url.scheme', + /* !!! Miscellaneous !!! */ /** * Database system names. diff --git a/lib/otel/segments/server.js b/lib/otel/segments/server.js index 97ecfb147b..ae1c33cb37 100644 --- a/lib/otel/segments/server.js +++ b/lib/otel/segments/server.js @@ -6,12 +6,14 @@ 'use strict' const Transaction = require('../../transaction') +const httpRecorder = require('../../metrics/recorders/http') const urltils = require('../../util/urltils') -const url = require('url') +const url = require('node:url') const DESTINATION = Transaction.DESTINATIONS.TRANS_COMMON const { ATTR_HTTP_METHOD, + ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE, ATTR_HTTP_URL, ATTR_RPC_METHOD, @@ -23,7 +25,7 @@ module.exports = function createServerSegment(agent, otelSpan) { const transaction = new Transaction(agent) transaction.type = 'web' const rpcSystem = otelSpan.attributes[ATTR_RPC_SYSTEM] - const httpMethod = otelSpan.attributes[ATTR_HTTP_METHOD] + const httpMethod = otelSpan.attributes[ATTR_HTTP_METHOD] ?? otelSpan.attributes[ATTR_HTTP_REQUEST_METHOD] let segment if (rpcSystem) { segment = rpcSegment({ agent, otelSpan, transaction, rpcSystem }) @@ -46,6 +48,7 @@ function rpcSegment({ agent, otelSpan, transaction, rpcSystem }) { transaction.url = name const segment = agent.tracer.createSegment({ name, + recorder: httpRecorder, parent: transaction.trace.root, transaction }) @@ -69,6 +72,7 @@ function httpSegment({ agent, otelSpan, transaction, httpMethod }) { transaction.trace.attributes.addAttribute(DESTINATION, 'request.method', httpMethod) return agent.tracer.createSegment({ name, + recorder: httpRecorder, parent: transaction.trace.root, transaction }) @@ -79,6 +83,7 @@ function genericHttpSegment({ agent, transaction }) { transaction.name = name return agent.tracer.createSegment({ name, + recorder: httpRecorder, parent: transaction.trace.root, transaction }) diff --git a/lib/otel/span-processor.js b/lib/otel/span-processor.js index 09234ed687..880b2f9cbf 100644 --- a/lib/otel/span-processor.js +++ b/lib/otel/span-processor.js @@ -12,8 +12,10 @@ const { ATTR_DB_NAME, ATTR_DB_STATEMENT, ATTR_DB_SYSTEM, + ATTR_HTTP_HOST, ATTR_NET_PEER_NAME, ATTR_NET_PEER_PORT, + ATTR_SERVER_ADDRESS, } = require('./constants') module.exports = class NrSpanProcessor { @@ -60,7 +62,7 @@ module.exports = class NrSpanProcessor { let sanitized = value if (key === ATTR_NET_PEER_PORT) { key = 'port_path_or_id' - } else if (prop === ATTR_NET_PEER_NAME) { + } else if (prop === ATTR_NET_PEER_NAME || prop === ATTR_SERVER_ADDRESS || prop === ATTR_HTTP_HOST) { key = 'host' if (urltils.isLocalhost(sanitized)) { sanitized = this.agent.config.getHostnameSafe(sanitized) diff --git a/test/versioned/otel-bridge/span.test.js b/test/versioned/otel-bridge/span.test.js index f1605bb7ee..77cd813eea 100644 --- a/test/versioned/otel-bridge/span.test.js +++ b/test/versioned/otel-bridge/span.test.js @@ -4,13 +4,33 @@ */ 'use strict' + const assert = require('node:assert') const test = require('node:test') -const helper = require('../../lib/agent_helper') const otel = require('@opentelemetry/api') const { hrTimeToMilliseconds } = require('@opentelemetry/core') + +const helper = require('../../lib/agent_helper') const { otelSynthesis } = require('../../../lib/symbols') -const { SEMATTRS_HTTP_HOST, SEMATTRS_HTTP_METHOD, SEMATTRS_DB_NAME, SEMATTRS_DB_STATEMENT, SEMATTRS_DB_SYSTEM, SEMATTRS_NET_PEER_PORT, SEMATTRS_NET_PEER_NAME, DbSystemValues } = require('@opentelemetry/semantic-conventions') + +const { + ATTR_DB_NAME, + ATTR_DB_STATEMENT, + ATTR_DB_SYSTEM, + ATTR_HTTP_HOST, + ATTR_HTTP_METHOD, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_ROUTE, + ATTR_NET_PEER_NAME, + ATTR_NET_PEER_PORT, + ATTR_RPC_METHOD, + ATTR_RPC_SERVICE, + ATTR_RPC_SYSTEM, + ATTR_SERVER_ADDRESS, + ATTR_URL_PATH, + ATTR_URL_SCHEME, + DB_SYSTEM_VALUES +} = require('../../../lib/otel/constants.js') test.beforeEach((ctx) => { const agent = helper.instrumentMockedAgent({ @@ -85,7 +105,7 @@ test('Otel http external span test', (t, end) => { const { agent, tracer } = t.nr helper.runInTransaction(agent, (tx) => { tx.name = 'http-external-test' - tracer.startActiveSpan('http-outbound', { kind: otel.SpanKind.CLIENT, attributes: { [SEMATTRS_HTTP_HOST]: 'newrelic.com', [SEMATTRS_HTTP_METHOD]: 'GET' } }, (span) => { + tracer.startActiveSpan('http-outbound', { kind: otel.SpanKind.CLIENT, attributes: { [ATTR_HTTP_HOST]: 'newrelic.com', [ATTR_HTTP_METHOD]: 'GET' } }, (span) => { const segment = agent.tracer.getSegment() assert.equal(segment.name, 'External/newrelic.com') span.end() @@ -107,11 +127,11 @@ test('Otel http external span test', (t, end) => { test('Otel db client span statement test', (t, end) => { const { agent, tracer } = t.nr const attributes = { - [SEMATTRS_DB_NAME]: 'test-db', - [SEMATTRS_DB_SYSTEM]: 'postgresql', - [SEMATTRS_DB_STATEMENT]: "select foo from test where foo = 'bar';", - [SEMATTRS_NET_PEER_PORT]: 5436, - [SEMATTRS_NET_PEER_NAME]: '127.0.0.1' + [ATTR_DB_NAME]: 'test-db', + [ATTR_DB_SYSTEM]: 'postgresql', + [ATTR_DB_STATEMENT]: "select foo from test where foo = 'bar';", + [ATTR_NET_PEER_PORT]: 5436, + [ATTR_NET_PEER_NAME]: '127.0.0.1' } const expectedHost = agent.config.getHostnameSafe('127.0.0.1') helper.runInTransaction(agent, (tx) => { @@ -152,10 +172,10 @@ test('Otel db client span statement test', (t, end) => { test('Otel db client span operation test', (t, end) => { const { agent, tracer } = t.nr const attributes = { - [SEMATTRS_DB_SYSTEM]: DbSystemValues.REDIS, - [SEMATTRS_DB_STATEMENT]: 'hset has random random', - [SEMATTRS_NET_PEER_PORT]: 5436, - [SEMATTRS_NET_PEER_NAME]: '127.0.0.1' + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUES.REDIS, + [ATTR_DB_STATEMENT]: 'hset has random random', + [ATTR_NET_PEER_PORT]: 5436, + [ATTR_NET_PEER_NAME]: '127.0.0.1' } const expectedHost = agent.config.getHostnameSafe('127.0.0.1') helper.runInTransaction(agent, (tx) => { @@ -189,3 +209,147 @@ test('Otel db client span operation test', (t, end) => { }) }) }) + +test('http metrics are bridged correctly', (t, end) => { + const { agent, tracer } = t.nr + + // Required span attributes for incoming HTTP server spans as defined by: + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server-semantic-conventions + const attributes = { + [ATTR_URL_SCHEME]: 'http', + [ATTR_SERVER_ADDRESS]: 'newrelic.com', + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + [ATTR_URL_PATH]: '/foo/bar', + [ATTR_HTTP_ROUTE]: '/foo/:param' + } + + tracer.startActiveSpan('http-test', { kind: otel.SpanKind.SERVER, attributes }, (span) => { + const tx = agent.getTransaction() + const segment = agent.tracer.getSegment() + assert.equal(segment.name, 'WebTransaction/Nodejs/GET//foo/:param') + span.end() + + const duration = hrTimeToMilliseconds(span.duration) + assert.equal(duration, segment.getDurationInMillis()) + tx.end() + + const attrs = segment.getAttributes() + assert.equal(attrs.host, 'newrelic.com') + assert.equal(attrs['http.request.method'], 'GET') + assert.equal(attrs['http.route'], '/foo/:param') + assert.equal(attrs['url.path'], '/foo/bar') + assert.equal(attrs['url.scheme'], 'http') + assert.equal(attrs.nr_exclusive_duration_millis, duration) + + const unscopedMetrics = tx.metrics.unscoped + const expectedMetrics = [ + 'HttpDispatcher', + 'WebTransaction', + 'WebTransaction/Nodejs/GET//foo/:param', + 'WebTransactionTotalTime', + 'WebTransactionTotalTime/null', + segment.name + ] + for (const expectedMetric of expectedMetrics) { + assert.equal(unscopedMetrics[expectedMetric].callCount, 1, `${expectedMetric} has correct callCount`) + } + assert.equal(unscopedMetrics.Apdex.apdexT, 0.1) + assert.equal(unscopedMetrics['Apdex/null'].apdexT, 0.1) + + end() + }) +}) + +test('rpc server metrics are bridged correctly', (t, end) => { + const { agent, tracer } = t.nr + + // Required span attributes for incoming HTTP server spans as defined by: + // https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#client-attributes + const attributes = { + [ATTR_RPC_SYSTEM]: 'foo', + [ATTR_RPC_METHOD]: 'getData', + [ATTR_RPC_SERVICE]: 'test.service', + [ATTR_SERVER_ADDRESS]: 'newrelic.com', + [ATTR_URL_PATH]: '/foo/bar' + } + + tracer.startActiveSpan('http-test', { kind: otel.SpanKind.SERVER, attributes }, (span) => { + const tx = agent.getTransaction() + const segment = agent.tracer.getSegment() + assert.equal(segment.name, 'WebTransaction/WebFrameworkUri/foo/test.service.getData') + span.end() + + const duration = hrTimeToMilliseconds(span.duration) + assert.equal(duration, segment.getDurationInMillis()) + tx.end() + + const attrs = segment.getAttributes() + assert.equal(attrs.host, 'newrelic.com') + assert.equal(attrs['rpc.system'], 'foo') + assert.equal(attrs['rpc.method'], 'getData') + assert.equal(attrs['rpc.service'], 'test.service') + assert.equal(attrs['url.path'], '/foo/bar') + assert.equal(attrs.nr_exclusive_duration_millis, duration) + + const unscopedMetrics = tx.metrics.unscoped + const expectedMetrics = [ + 'HttpDispatcher', + 'WebTransaction', + 'WebTransaction/WebFrameworkUri/foo/test.service.getData', + 'WebTransactionTotalTime', + 'WebTransactionTotalTime/null', + segment.name + ] + for (const expectedMetric of expectedMetrics) { + assert.equal(unscopedMetrics[expectedMetric].callCount, 1, `${expectedMetric} has correct callCount`) + } + assert.equal(unscopedMetrics.Apdex.apdexT, 0.1) + assert.equal(unscopedMetrics['Apdex/null'].apdexT, 0.1) + + end() + }) +}) + +test('fallback metrics are bridged correctly', (t, end) => { + const { agent, tracer } = t.nr + + const attributes = { + [ATTR_URL_SCHEME]: 'gopher', + [ATTR_SERVER_ADDRESS]: 'newrelic.com', + [ATTR_URL_PATH]: '/foo/bar', + } + + tracer.startActiveSpan('http-test', { kind: otel.SpanKind.SERVER, attributes }, (span) => { + const tx = agent.getTransaction() + const segment = agent.tracer.getSegment() + assert.equal(segment.name, 'WebTransaction/NormalizedUri/*') + span.end() + + const duration = hrTimeToMilliseconds(span.duration) + assert.equal(duration, segment.getDurationInMillis()) + tx.end() + + const attrs = segment.getAttributes() + assert.equal(attrs.host, 'newrelic.com') + assert.equal(attrs['url.path'], '/foo/bar') + assert.equal(attrs['url.scheme'], 'gopher') + assert.equal(attrs.nr_exclusive_duration_millis, duration) + + const unscopedMetrics = tx.metrics.unscoped + const expectedMetrics = [ + 'HttpDispatcher', + 'WebTransaction', + 'WebTransaction/NormalizedUri/*', + 'WebTransactionTotalTime', + 'WebTransactionTotalTime/null', + segment.name + ] + for (const expectedMetric of expectedMetrics) { + assert.equal(unscopedMetrics[expectedMetric].callCount, 1, `${expectedMetric} has correct callCount`) + } + assert.equal(unscopedMetrics.Apdex.apdexT, 0.1) + assert.equal(unscopedMetrics['Apdex/null'].apdexT, 0.1) + + end() + }) +})