diff --git a/lib/otel/segment-synthesis.js b/lib/otel/segment-synthesis.js index dddc89ff55..bebc9ba287 100644 --- a/lib/otel/segment-synthesis.js +++ b/lib/otel/segment-synthesis.js @@ -6,17 +6,7 @@ 'use strict' const { RulesEngine } = require('./rules') const defaultLogger = require('../logger').child({ component: 'segment-synthesizer' }) -const NAMES = require('../metrics/names') -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') +const { DatabaseSegment, HttpExternalSegment, ServerSegment } = require('./segments') class SegmentSynthesizer { constructor(agent, { logger = defaultLogger } = {}) { @@ -36,74 +26,16 @@ class SegmentSynthesizer { return } - if (rule.type === 'external') { - return this.createExternalSegment(otelSpan) - } else if (rule.type === 'db') { - return this.createDatabaseSegment(otelSpan) + switch (rule.type) { + case 'external': + return new HttpExternalSegment(this.agent, otelSpan) + case 'db': + return new DatabaseSegment(this.agent, otelSpan) + case 'server': + return new ServerSegment(this.agent, otelSpan) + default: + this.logger.debug('Found type: %s, no synthesis 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 - // where external segments are created in our agent - createExternalSegment(otelSpan) { - const context = this.agent.tracer.getContext() - const host = otelSpan.attributes[SEMATTRS_HTTP_HOST] || 'Unknown' - const name = NAMES.EXTERNAL.PREFIX + host - return this.agent.tracer.createSegment({ - name, - parent: context.segment, - 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 - }) } } diff --git a/lib/otel/segments/database.js b/lib/otel/segments/database.js new file mode 100644 index 0000000000..4c37f86830 --- /dev/null +++ b/lib/otel/segments/database.js @@ -0,0 +1,70 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' +const { + 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') + +// TODO: This probably has some holes +// I did analysis and tried to apply the best logic +// to extract table/operation +module.exports = class DatabaseSegment { + constructor(agent, otelSpan) { + const context = agent.tracer.getContext() + const name = this.setName(otelSpan) + const segment = agent.tracer.createSegment({ + name, + parent: context.segment, + transaction: context.transaction + }) + return { segment, 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 } + } + + setName(otelSpan) { + 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 name + } +} diff --git a/lib/otel/segments/http-external.js b/lib/otel/segments/http-external.js new file mode 100644 index 0000000000..581650aae4 --- /dev/null +++ b/lib/otel/segments/http-external.js @@ -0,0 +1,22 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' +const NAMES = require('../../metrics/names') +const { SEMATTRS_HTTP_HOST } = require('@opentelemetry/semantic-conventions') + +module.exports = class HttpExternalSegment { + constructor(agent, otelSpan) { + const context = agent.tracer.getContext() + const host = otelSpan.attributes[SEMATTRS_HTTP_HOST] || 'Unknown' + const name = NAMES.EXTERNAL.PREFIX + host + const segment = agent.tracer.createSegment({ + name, + parent: context.segment, + transaction: context.transaction + }) + return { segment, transaction: context.transaction } + } +} diff --git a/lib/otel/segments/index.js b/lib/otel/segments/index.js new file mode 100644 index 0000000000..019bbf2829 --- /dev/null +++ b/lib/otel/segments/index.js @@ -0,0 +1,15 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' +const HttpExternalSegment = require('./http-external') +const DatabaseSegment = require('./database') +const ServerSegment = require('./server') + +module.exports = { + DatabaseSegment, + HttpExternalSegment, + ServerSegment +} diff --git a/lib/otel/segments/server.js b/lib/otel/segments/server.js new file mode 100644 index 0000000000..6046eff487 --- /dev/null +++ b/lib/otel/segments/server.js @@ -0,0 +1,88 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +const { + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_ROUTE, + SEMATTRS_HTTP_URL, + SEMATTRS_RPC_SYSTEM, + SEMATTRS_RPC_SERVICE, + SEMATTRS_RPC_METHOD +} = require('@opentelemetry/semantic-conventions') +const { DESTINATIONS } = require('../../config/attribute-filter') +const DESTINATION = DESTINATIONS.TRANS_COMMON +const Transaction = require('../../transaction') +const urltils = require('../../util/urltils') +const url = require('url') + +module.exports = class ServerSegment { + constructor(agent, otelSpan) { + this.agent = agent + this.transaction = new Transaction(agent) + this.transaction.type = 'web' + this.otelSpan = otelSpan + const rpcSystem = otelSpan.attributes[SEMATTRS_RPC_SYSTEM] + const httpMethod = otelSpan.attributes[SEMATTRS_HTTP_METHOD] + if (rpcSystem) { + this.segment = this.rpcSegment(rpcSystem) + } else if (httpMethod) { + this.segment = this.httpSegment(httpMethod) + } else { + this.segment = this.genericHttpSegment() + } + this.transaction.baseSegment = this.segment + return { segment: this.segment, transaction: this.transaction } + } + + rpcSegment(rpcSystem) { + const rpcService = this.otelSpan.attributes[SEMATTRS_RPC_SERVICE] || 'Unknown' + const rpcMethod = this.otelSpan.attributes[SEMATTRS_RPC_METHOD] || 'Unknown' + const name = `WebTransaction/WebFrameworkUri/${rpcSystem}/${rpcService}.${rpcMethod}` + this.transaction.name = name + this.transaction.trace.attributes.addAttribute(DESTINATION, 'request.method', rpcMethod) + this.transaction.trace.attributes.addAttribute(DESTINATION, 'request.uri', name) + this.transaction.url = name + const segment = this.agent.tracer.createSegment({ + name, + parent: this.transaction.trace.root, + transaction: this.transaction + }) + segment.addAttribute('component', rpcSystem) + return segment + } + + // most instrumentation will hit this case + // I find that if the request is in a web framework, the web framework instrumentation + // sets `http.route` and when the span closes it pulls that attribute in + // we'll most likely need to wire up some naming reconciliation + // to handle this use case. + httpSegment(httpMethod) { + const httpRoute = this.otelSpan.attributes[SEMATTRS_HTTP_ROUTE] || 'Unknown' + const httpUrl = this.otelSpan.attributes[SEMATTRS_HTTP_URL] || '/Unknown' + const requestUrl = url.parse(httpUrl, true) + const name = `WebTransaction/Nodejs/${httpMethod}/${httpRoute}` + this.transaction.name = name + this.transaction.url = urltils.obfuscatePath(this.agent.config, requestUrl.pathname) + this.transaction.trace.attributes.addAttribute(DESTINATION, 'request.uri', this.transaction.url) + this.transaction.trace.attributes.addAttribute(DESTINATION, 'request.method', httpMethod) + return this.agent.tracer.createSegment({ + name, + parent: this.transaction.trace.root, + transaction: this.transaction + }) + } + + genericHttpSegment() { + const name = 'WebTransaction/NormalizedUri/*' + this.transaction.name = name + return this.agent.tracer.createSegment({ + name, + parent: this.transaction.trace.root, + transaction: this.transaction + }) + } +} diff --git a/test/unit/lib/otel/fixtures/index.js b/test/unit/lib/otel/fixtures/index.js index 22d7094a06..1383fae5c9 100644 --- a/test/unit/lib/otel/fixtures/index.js +++ b/test/unit/lib/otel/fixtures/index.js @@ -13,13 +13,17 @@ const { } = require('./db-sql') const createSpan = require('./span') const createHttpClientSpan = require('./http-client') +const { createRpcServerSpan, createHttpServerSpan, createBaseHttpSpan } = require('./server') module.exports = { + createBaseHttpSpan, createDbClientSpan, createDbStatementSpan, createHttpClientSpan, + createHttpServerSpan, createMemcachedDbSpan, createMongoDbSpan, createRedisDbSpan, + createRpcServerSpan, createSpan } diff --git a/test/unit/lib/otel/fixtures/server.js b/test/unit/lib/otel/fixtures/server.js new file mode 100644 index 0000000000..fb9b6f8be0 --- /dev/null +++ b/test/unit/lib/otel/fixtures/server.js @@ -0,0 +1,43 @@ +/* + * Copyright 2024 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' +const { + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_ROUTE, + SEMATTRS_HTTP_URL, + SEMATTRS_RPC_SYSTEM, + SEMATTRS_RPC_SERVICE, + SEMATTRS_RPC_METHOD +} = require('@opentelemetry/semantic-conventions') + +const { SpanKind } = require('@opentelemetry/api') +const createSpan = require('./span') + +function createRpcServerSpan({ tracer, name = 'test-span' }) { + const span = createSpan({ name, kind: SpanKind.SERVER, tracer }) + span.setAttribute(SEMATTRS_RPC_SYSTEM, 'grpc') + span.setAttribute(SEMATTRS_RPC_METHOD, 'findUser') + span.setAttribute(SEMATTRS_RPC_SERVICE, 'TestService') + return span +} + +function createHttpServerSpan({ tracer, name = 'test-span' }) { + const span = createSpan({ name, kind: SpanKind.SERVER, tracer }) + span.setAttribute(SEMATTRS_HTTP_METHOD, 'PUT') + span.setAttribute(SEMATTRS_HTTP_ROUTE, '/user/:id') + span.setAttribute(SEMATTRS_HTTP_URL, '/user/1') + return span +} + +function createBaseHttpSpan({ tracer, name = 'test-span' }) { + return createSpan({ name, kind: SpanKind.SERVER, tracer }) +} + +module.exports = { + createBaseHttpSpan, + createHttpServerSpan, + createRpcServerSpan +} diff --git a/test/unit/lib/otel/fixtures/span.js b/test/unit/lib/otel/fixtures/span.js index cfa8117dff..6405679134 100644 --- a/test/unit/lib/otel/fixtures/span.js +++ b/test/unit/lib/otel/fixtures/span.js @@ -9,8 +9,8 @@ const { Span } = require('@opentelemetry/sdk-trace-base') module.exports = function createSpan({ parentId, tracer, tx, kind, name }) { const spanContext = { - traceId: tx.trace.id, - spanId: tx.trace.root.id, + traceId: tx?.trace?.id, + spanId: tx?.trace?.root?.id, traceFlags: TraceFlags.SAMPLED } return new Span(tracer, ROOT_CONTEXT, name, spanContext, kind, parentId) diff --git a/test/unit/lib/otel/segment-synthesizer.test.js b/test/unit/lib/otel/segment-synthesizer.test.js index c00af4b1e5..d717c5a0b2 100644 --- a/test/unit/lib/otel/segment-synthesizer.test.js +++ b/test/unit/lib/otel/segment-synthesizer.test.js @@ -12,16 +12,20 @@ const { BasicTracerProvider } = require('@opentelemetry/sdk-trace-base') const SegmentSynthesizer = require('../../../../lib/otel/segment-synthesis') const createMockLogger = require('../../mocks/logger') const { + createBaseHttpSpan, createDbClientSpan, createSpan, createHttpClientSpan, + createHttpServerSpan, createDbStatementSpan, createMongoDbSpan, createRedisDbSpan, + createRpcServerSpan, createMemcachedDbSpan } = require('./fixtures') -const { SEMATTRS_HTTP_METHOD, SEMATTRS_DB_SYSTEM } = require('@opentelemetry/semantic-conventions') +const { SEMATTRS_DB_SYSTEM } = require('@opentelemetry/semantic-conventions') const { SpanKind } = require('@opentelemetry/api') +const { DESTINATIONS } = require('../../../../lib/config/attribute-filter') test.beforeEach((ctx) => { const loggerMock = createMockLogger() @@ -46,7 +50,8 @@ test('should create http external segment from otel http client span', (t, end) const { agent, synthesizer, parentId, tracer } = t.nr helper.runInTransaction(agent, (tx) => { const span = createHttpClientSpan({ tx, parentId, tracer }) - const segment = synthesizer.synthesize(span) + const { segment, transaction } = synthesizer.synthesize(span) + assert.equal(tx.id, transaction.id) assert.equal(segment.name, 'External/newrelic.com') assert.equal(segment.parentId, tx.trace.root.id) tx.end() @@ -58,7 +63,8 @@ test('should create db segment', (t, end) => { const { agent, synthesizer, parentId, tracer } = t.nr helper.runInTransaction(agent, (tx) => { const span = createDbClientSpan({ tx, parentId, tracer }) - const segment = synthesizer.synthesize(span) + const { segment, transaction } = synthesizer.synthesize(span) + assert.equal(tx.id, transaction.id) assert.equal(segment.name, 'Datastore/statement/custom-db/test-table/select') assert.equal(segment.parentId, tx.trace.root.id) tx.end() @@ -70,7 +76,8 @@ test('should create db segment and get operation and table from db.statement', ( const { agent, synthesizer, parentId, tracer } = t.nr helper.runInTransaction(agent, (tx) => { const span = createDbStatementSpan({ tx, parentId, tracer }) - const segment = synthesizer.synthesize(span) + const { segment, transaction } = synthesizer.synthesize(span) + assert.equal(tx.id, transaction.id) assert.equal(segment.name, 'Datastore/statement/custom-db/test-table/select') assert.equal(segment.parentId, tx.trace.root.id) tx.end() @@ -82,7 +89,8 @@ test('should create db segment and get collection from db.mongodb.collection', ( const { agent, synthesizer, parentId, tracer } = t.nr helper.runInTransaction(agent, (tx) => { const span = createMongoDbSpan({ tx, parentId, tracer }) - const segment = synthesizer.synthesize(span) + const { segment, transaction } = synthesizer.synthesize(span) + assert.equal(tx.id, transaction.id) assert.equal(segment.name, 'Datastore/statement/mongodb/test-collection/insert') assert.equal(segment.parentId, tx.trace.root.id) tx.end() @@ -94,7 +102,8 @@ test('should create db segment and get operation from db.statement when system i const { agent, synthesizer, parentId, tracer } = t.nr helper.runInTransaction(agent, (tx) => { const span = createRedisDbSpan({ tx, parentId, tracer }) - const segment = synthesizer.synthesize(span) + const { segment, transaction } = synthesizer.synthesize(span) + assert.equal(tx.id, transaction.id) assert.equal(segment.name, 'Datastore/operation/redis/hset') assert.equal(segment.parentId, tx.trace.root.id) tx.end() @@ -106,7 +115,8 @@ test('should create db segment and get operation from db.operation when system i const { agent, synthesizer, parentId, tracer } = t.nr helper.runInTransaction(agent, (tx) => { const span = createMemcachedDbSpan({ tx, parentId, tracer }) - const segment = synthesizer.synthesize(span) + const { segment, transaction } = synthesizer.synthesize(span) + assert.equal(tx.id, transaction.id) assert.equal(segment.name, 'Datastore/operation/memcached/set') assert.equal(segment.parentId, tx.trace.root.id) tx.end() @@ -120,7 +130,8 @@ test('should log table and operation as unknown when the db.system, db.sql.table const span = createSpan({ name: 'test-span', kind: SpanKind.CLIENT, parentId, tx, tracer }) span.setAttribute(SEMATTRS_DB_SYSTEM, 'test-db') - const segment = synthesizer.synthesize(span) + const { segment, transaction } = synthesizer.synthesize(span) + assert.equal(tx.id, transaction.id) assert.equal(segment.name, 'Datastore/statement/test-db/Unknown/Unknown') assert.equal(segment.parentId, tx.trace.root.id) tx.end() @@ -128,21 +139,51 @@ test('should log table and operation as unknown when the db.system, db.sql.table }) }) -test('should log warning when span does not have a synthesis rule', (t, end) => { - const { agent, synthesizer, loggerMock, parentId, tracer } = t.nr +test('should create rpc segment', (t) => { + const { synthesizer, tracer } = t.nr + const span = createRpcServerSpan({ tracer }) + const { segment, transaction } = synthesizer.synthesize(span) + const expectedName = 'WebTransaction/WebFrameworkUri/grpc/TestService.findUser' + assert.equal(segment.name, expectedName) + assert.equal(segment.parentId, segment.root.id) + assert.ok(transaction) + assert.equal(transaction.name, expectedName) + const segmentAttrs = segment.getAttributes() + assert.equal(segmentAttrs.component, 'grpc') + assert.equal(transaction.url, expectedName) + assert.equal(transaction.baseSegment.name, segment.name) + const attrs = transaction.trace.attributes.get(DESTINATIONS.TRANS_TRACE) + assert.equal(attrs['request.method'], 'findUser') + assert.equal(attrs['request.uri'], expectedName) +}) - helper.runInTransaction(agent, (tx) => { - const span = createSpan({ name: 'test-span', kind: SpanKind.SERVER, parentId, tx, tracer }) - span.setAttribute(SEMATTRS_HTTP_METHOD, 'get') - const segment = synthesizer.synthesize(span) - assert.ok(!segment) - assert.deepEqual(loggerMock.debug.args[0], [ - 'Found type: %s, no synthesis rule currently built', - 'server' - ]) - tx.end() - end() - }) +test('should create http server segment', (t) => { + const { synthesizer, tracer } = t.nr + const span = createHttpServerSpan({ tracer }) + const { segment, transaction } = synthesizer.synthesize(span) + const expectedName = 'WebTransaction/Nodejs/PUT//user/:id' + assert.equal(segment.name, expectedName) + assert.equal(segment.parentId, segment.root.id) + assert.ok(transaction) + assert.equal(transaction.name, expectedName) + assert.equal(transaction.url, '/user/1') + assert.equal(transaction.baseSegment.name, segment.name) + const attrs = transaction.trace.attributes.get(DESTINATIONS.TRANS_TRACE) + assert.equal(attrs['request.method'], 'PUT') + assert.equal(attrs['request.uri'], '/user/1') + transaction.end() +}) + +test('should create base http server segment', (t) => { + const { synthesizer, tracer } = t.nr + const span = createBaseHttpSpan({ tracer }) + const { segment, transaction } = synthesizer.synthesize(span) + const expectedName = 'WebTransaction/NormalizedUri/*' + assert.equal(segment.name, expectedName) + assert.equal(segment.parentId, segment.root.id) + assert.equal(transaction.baseSegment.name, segment.name) + assert.ok(transaction) + assert.equal(transaction.name, expectedName) }) test('should log warning span does not match a rule', (t, end) => { @@ -150,8 +191,8 @@ test('should log warning span does not match a rule', (t, end) => { helper.runInTransaction(agent, (tx) => { const span = createSpan({ name: 'test-span', kind: 'bogus', parentId, tx, tracer }) - const segment = synthesizer.synthesize(span) - assert.ok(!segment) + const data = synthesizer.synthesize(span) + assert.ok(!data) assert.deepEqual(loggerMock.debug.args[0], [ 'Cannot match a rule to span name: %s, kind %s', 'test-span',