Skip to content

Commit 8606f78

Browse files
authored
feat: Added timeslice metrics recorders for synthesized db segments (#2922)
1 parent c7ae8be commit 8606f78

File tree

3 files changed

+203
-22
lines changed

3 files changed

+203
-22
lines changed

lib/otel/segments/database.js

+73-11
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,70 @@ const {
1313
DbSystemValues
1414
} = require('@opentelemetry/semantic-conventions')
1515
const parseSql = require('../../db/query-parsers/sql')
16+
const recordQueryMetrics = require('../../metrics/recorders/database')
17+
const recordOperationMetrics = require('../../metrics/recorders/database-operation')
18+
const ParsedStatement = require('../../db/parsed-statement')
19+
const metrics = require('../../metrics/names')
1620

1721
// TODO: This probably has some holes
1822
// I did analysis and tried to apply the best logic
1923
// to extract table/operation
2024
module.exports = function createDbSegment(agent, otelSpan) {
2125
const context = agent.tracer.getContext()
22-
const name = setName(otelSpan)
26+
const system = otelSpan.attributes[SEMATTRS_DB_SYSTEM]
27+
const parsed = parseStatement(agent.config, otelSpan, system)
28+
const { name, operation } = setName(parsed)
2329
const segment = agent.tracer.createSegment({
2430
name,
31+
recorder: getRecorder({ operation, parsed, system }),
2532
parent: context.segment,
2633
transaction: context.transaction
2734
})
2835
return { segment, transaction: context.transaction }
2936
}
3037

31-
function parseStatement(otelSpan, system) {
38+
/**
39+
* Assigns the appropriate timeslice metrics recorder
40+
* based on the otel span.
41+
*
42+
* @param {object} params to fn
43+
* @param {ParsedStatement} params.parsed parsed statement of call
44+
* @param {boolean} params.operation if span is an operation
45+
* @param {string} params.system `db.system` value of otel span
46+
* @returns {function} returns a timeslice metrics recorder function based on span
47+
*/
48+
function getRecorder({ parsed, operation, system }) {
49+
if (operation) {
50+
/**
51+
* this metrics recorder expects to bound with
52+
* a datastore-shim. But really the only thing it needs
53+
* is `_metrics` with values for PREFIX and ALL
54+
* This assigns what it needs to properly create
55+
* the db operation time slice metrics
56+
*/
57+
const scope = {}
58+
scope._metrics = {
59+
PREFIX: system,
60+
ALL: metrics.DB.PREFIX + system + '/' + metrics.ALL
61+
}
62+
return recordOperationMetrics.bind(scope)
63+
} else {
64+
return recordQueryMetrics.bind(parsed)
65+
}
66+
}
67+
68+
/**
69+
* Creates a parsed statement from various span attributes.
70+
*
71+
* @param {object} config agent config
72+
* @param {object} otelSpan span getting parsed
73+
* @param {string} system value of `db.system` on span
74+
* @returns {ParsedStatement} instance of parsed statement
75+
*/
76+
function parseStatement(config, otelSpan, system) {
3277
let table = otelSpan.attributes[SEMATTRS_DB_SQL_TABLE]
3378
let operation = otelSpan.attributes[SEMATTRS_DB_OPERATION]
34-
const statement = otelSpan.attributes[SEMATTRS_DB_STATEMENT]
79+
let statement = otelSpan.attributes[SEMATTRS_DB_STATEMENT]
3580
if (statement && !(table || operation)) {
3681
const parsed = parseSql({ sql: statement })
3782
if (parsed.operation && !operation) {
@@ -41,6 +86,7 @@ function parseStatement(otelSpan, system) {
4186
if (parsed.collection && !table) {
4287
table = parsed.collection
4388
}
89+
statement = parsed.query
4490
}
4591
if (system === DbSystemValues.MONGODB) {
4692
table = otelSpan.attributes[SEMATTRS_DB_MONGODB_COLLECTION]
@@ -52,17 +98,33 @@ function parseStatement(otelSpan, system) {
5298

5399
table = table || 'Unknown'
54100
operation = operation || 'Unknown'
101+
const queryRecorded =
102+
config.transaction_tracer.record_sql === 'raw' ||
103+
config.transaction_tracer.record_sql === 'obfuscated'
55104

56-
return { operation, table }
105+
return new ParsedStatement(
106+
system,
107+
operation,
108+
table,
109+
queryRecorded ? statement : null
110+
)
57111
}
58112

59-
function setName(otelSpan) {
60-
const system = otelSpan.attributes[SEMATTRS_DB_SYSTEM]
61-
const { operation, table } = parseStatement(otelSpan, system)
62-
let name = `Datastore/statement/${system}/${table}/${operation}`
113+
/**
114+
* Creates name for db segment based on otel span
115+
* If the system is redis or memcached the name is an operation name
116+
*
117+
* @param {ParsedStatement} parsed statement used for naming segment
118+
* @returns {string} name of segment
119+
*/
120+
function setName(parsed) {
121+
let operation = false
122+
let name = `Datastore/statement/${parsed.type}/${parsed.collection}/${parsed.operation}`
63123
// All segment name shapes are same except redis/memcached
64-
if (system === DbSystemValues.REDIS || system === DbSystemValues.MEMCACHED) {
65-
name = `Datastore/operation/${system}/${operation}`
124+
if (parsed.type === DbSystemValues.REDIS || parsed.type === DbSystemValues.MEMCACHED) {
125+
name = `Datastore/operation/${parsed.type}/${parsed.operation}`
126+
operation = true
66127
}
67-
return name
128+
129+
return { name, operation }
68130
}

lib/otel/span-processor.js

+43-10
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
const SegmentSynthesizer = require('./segment-synthesis')
88
const { otelSynthesis } = require('../symbols')
99
const { hrTimeToMilliseconds } = require('@opentelemetry/core')
10+
const urltils = require('../util/urltils')
11+
const { SEMATTRS_NET_PEER_PORT, SEMATTRS_NET_PEER_NAME, SEMATTRS_DB_NAME, SEMATTRS_DB_SYSTEM, SEMATTRS_DB_STATEMENT } = require('@opentelemetry/semantic-conventions')
1012

1113
module.exports = class NrSpanProcessor {
1214
constructor(agent) {
@@ -19,7 +21,7 @@ module.exports = class NrSpanProcessor {
1921
* Synthesize segment at start of span and assign to a symbol
2022
* that will be removed in `onEnd` once the correspondig
2123
* segment is read.
22-
* @param span
24+
* @param {object} span otel span getting tested
2325
*/
2426
onStart(span) {
2527
span[otelSynthesis] = this.synthesizer.synthesize(span)
@@ -28,20 +30,51 @@ module.exports = class NrSpanProcessor {
2830
/**
2931
* Update the segment duration from span and reconcile
3032
* any attributes that were added after the start
31-
* @param span
33+
* @param {object} span otel span getting updated
3234
*/
3335
onEnd(span) {
34-
this.updateDuration(span)
35-
// TODO: add attributes from span that did not exist at start
36-
}
37-
38-
updateDuration(span) {
3936
if (span[otelSynthesis] && span[otelSynthesis].segment) {
4037
const { segment } = span[otelSynthesis]
41-
segment.touch()
42-
const duration = hrTimeToMilliseconds(span.duration)
43-
segment.overwriteDurationInMillis(duration)
38+
this.updateDuration(segment, span)
39+
this.reconcileAttributes(segment, span)
4440
delete span[otelSynthesis]
4541
}
4642
}
43+
44+
updateDuration(segment, span) {
45+
segment.touch()
46+
const duration = hrTimeToMilliseconds(span.duration)
47+
segment.overwriteDurationInMillis(duration)
48+
}
49+
50+
// TODO: clean this up and break out by span.kind
51+
reconcileAttributes(segment, span) {
52+
for (const [prop, value] of Object.entries(span.attributes)) {
53+
let key = prop
54+
let sanitized = value
55+
if (key === SEMATTRS_NET_PEER_PORT) {
56+
key = 'port_path_or_id'
57+
} else if (prop === SEMATTRS_NET_PEER_NAME) {
58+
key = 'host'
59+
if (urltils.isLocalhost(sanitized)) {
60+
sanitized = this.agent.config.getHostnameSafe(sanitized)
61+
}
62+
} else if (prop === SEMATTRS_DB_NAME) {
63+
key = 'database_name'
64+
} else if (prop === SEMATTRS_DB_SYSTEM) {
65+
key = 'product'
66+
/**
67+
* This attribute was collected in `onStart`
68+
* and was passed to `ParsedStatement`. It adds
69+
* this segment attribute as `sql` or `sql_obfuscated`
70+
* and then when the span is built from segment
71+
* re-assigns to `db.statement`. This needs
72+
* to be skipped because it will be the raw value.
73+
*/
74+
} else if (prop === SEMATTRS_DB_STATEMENT) {
75+
continue
76+
}
77+
segment.addAttribute(key, sanitized)
78+
}
79+
}
4780
}

test/versioned/otel-bridge/span.test.js

+87-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const helper = require('../../lib/agent_helper')
1010
const otel = require('@opentelemetry/api')
1111
const { hrTimeToMilliseconds } = require('@opentelemetry/core')
1212
const { otelSynthesis } = require('../../../lib/symbols')
13-
const { SEMATTRS_HTTP_HOST, SEMATTRS_HTTP_METHOD } = require('@opentelemetry/semantic-conventions')
13+
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')
1414

1515
test.beforeEach((ctx) => {
1616
const agent = helper.instrumentMockedAgent({
@@ -103,3 +103,89 @@ test('Otel http external span test', (t, end) => {
103103
})
104104
})
105105
})
106+
107+
test('Otel db client span statement test', (t, end) => {
108+
const { agent, tracer } = t.nr
109+
const attributes = {
110+
[SEMATTRS_DB_NAME]: 'test-db',
111+
[SEMATTRS_DB_SYSTEM]: 'postgresql',
112+
[SEMATTRS_DB_STATEMENT]: "select foo from test where foo = 'bar';",
113+
[SEMATTRS_NET_PEER_PORT]: 5436,
114+
[SEMATTRS_NET_PEER_NAME]: '127.0.0.1'
115+
}
116+
const expectedHost = agent.config.getHostnameSafe('127.0.0.1')
117+
helper.runInTransaction(agent, (tx) => {
118+
tx.name = 'db-test'
119+
tracer.startActiveSpan('db-test', { kind: otel.SpanKind.CLIENT, attributes }, (span) => {
120+
const segment = agent.tracer.getSegment()
121+
assert.equal(segment.name, 'Datastore/statement/postgresql/test/select')
122+
span.end()
123+
const duration = hrTimeToMilliseconds(span.duration)
124+
assert.equal(duration, segment.getDurationInMillis())
125+
tx.end()
126+
const attrs = segment.getAttributes()
127+
assert.equal(attrs.host, expectedHost)
128+
assert.equal(attrs.product, 'postgresql')
129+
assert.equal(attrs.port_path_or_id, 5436)
130+
assert.equal(attrs.database_name, 'test-db')
131+
assert.equal(attrs.sql_obfuscated, 'select foo from test where foo = ?;')
132+
const metrics = tx.metrics.scoped[tx.name]
133+
assert.equal(metrics['Datastore/statement/postgresql/test/select'].callCount, 1)
134+
const unscopedMetrics = tx.metrics.unscoped
135+
;[
136+
'Datastore/all',
137+
'Datastore/allWeb',
138+
'Datastore/postgresql/all',
139+
'Datastore/postgresql/allWeb',
140+
'Datastore/operation/postgresql/select',
141+
'Datastore/statement/postgresql/test/select',
142+
`Datastore/instance/postgresql/${expectedHost}/5436`
143+
].forEach((expectedMetric) => {
144+
assert.equal(unscopedMetrics[expectedMetric].callCount, 1)
145+
})
146+
147+
end()
148+
})
149+
})
150+
})
151+
152+
test('Otel db client span operation test', (t, end) => {
153+
const { agent, tracer } = t.nr
154+
const attributes = {
155+
[SEMATTRS_DB_SYSTEM]: DbSystemValues.REDIS,
156+
[SEMATTRS_DB_STATEMENT]: 'hset has random random',
157+
[SEMATTRS_NET_PEER_PORT]: 5436,
158+
[SEMATTRS_NET_PEER_NAME]: '127.0.0.1'
159+
}
160+
const expectedHost = agent.config.getHostnameSafe('127.0.0.1')
161+
helper.runInTransaction(agent, (tx) => {
162+
tx.name = 'db-test'
163+
tracer.startActiveSpan('db-test', { kind: otel.SpanKind.CLIENT, attributes }, (span) => {
164+
const segment = agent.tracer.getSegment()
165+
assert.equal(segment.name, 'Datastore/operation/redis/hset')
166+
span.end()
167+
const duration = hrTimeToMilliseconds(span.duration)
168+
assert.equal(duration, segment.getDurationInMillis())
169+
tx.end()
170+
const attrs = segment.getAttributes()
171+
assert.equal(attrs.host, expectedHost)
172+
assert.equal(attrs.product, 'redis')
173+
assert.equal(attrs.port_path_or_id, 5436)
174+
const metrics = tx.metrics.scoped[tx.name]
175+
assert.equal(metrics['Datastore/operation/redis/hset'].callCount, 1)
176+
const unscopedMetrics = tx.metrics.unscoped
177+
;[
178+
'Datastore/all',
179+
'Datastore/allWeb',
180+
'Datastore/redis/all',
181+
'Datastore/redis/allWeb',
182+
'Datastore/operation/redis/hset',
183+
`Datastore/instance/redis/${expectedHost}/5436`
184+
].forEach((expectedMetric) => {
185+
assert.equal(unscopedMetrics[expectedMetric].callCount, 1)
186+
})
187+
188+
end()
189+
})
190+
})
191+
})

0 commit comments

Comments
 (0)