Skip to content

Commit e2d6dfa

Browse files
authored
fix: missing SQL-related attributes (#352)
closes #351
1 parent 041f24c commit e2d6dfa

File tree

8 files changed

+142
-20
lines changed

8 files changed

+142
-20
lines changed

.github/workflows/hana.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
workflow_dispatch:
8+
push:
9+
branches: [main]
10+
pull_request:
11+
branches: [main]
12+
13+
jobs:
14+
hana:
15+
runs-on: ubuntu-latest
16+
strategy:
17+
fail-fast: false
18+
max-parallel: 1
19+
matrix:
20+
hana-driver: [hdb, hana-client]
21+
hana-prom: [true, false]
22+
env:
23+
HANA_CREDS: ${{ secrets.HANA_CREDS }}
24+
HANA_DRIVER: ${{ matrix.hana-driver }}
25+
HANA_PROM: ${{ matrix.hana-prom }}
26+
steps:
27+
- uses: actions/checkout@v2
28+
- uses: actions/setup-node@v2
29+
- run: npm i -g @sap/cds-dk
30+
- run: npm add @cap-js/hana
31+
- run: npm add hdb
32+
- run: npm add @sap/hana-client
33+
- run: npm i
34+
- run: cds v
35+
- run: npm run test

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
This project adheres to [Semantic Versioning](http://semver.org/).
55
The format is based on [Keep a Changelog](http://keepachangelog.com/).
66

7+
## Version 1.4.1 - 2025-06-27
8+
9+
### Fixed
10+
11+
- Missing SQL-related attributes with `cds.requires.telemetry.tracing._hana_prom = true`
12+
713
## Version 1.4.0 - 2025-06-05
814

915
### Added

jest.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,14 @@ const config = {
33
testMatch: ['**/*.test.js']
44
}
55

6+
if (process.env.CI && process.env.HANA_DRIVER) {
7+
config.testTimeout *= 10
8+
config.testMatch = ['**/tracing-attributes.test.js']
9+
10+
process.env.cds_requires_db = JSON.stringify({ kind: 'hana', credentials: JSON.parse(process.env.HANA_CREDS) })
11+
12+
if (process.env.HANA_PROM)
13+
process.env.cds_requires_telemetry_tracing = JSON.stringify({ _hana_prom: process.env.HANA_PROM === 'true' })
14+
}
15+
616
module.exports = config

lib/tracing/cds.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const _wrapStmt = (stmt, impl, sql) => {
3030

3131
module.exports = () => {
3232
const { _tx, _hana_prom, _wrap_hana } = cds.env.requires.telemetry.tracing
33+
const _TX = { BEGIN: 1, COMMIT: 1, ROLLBACK: 1 }
3334

3435
const { emit: _emit, handle: _handle } = cds.Service.prototype
3536
cds.Service.prototype.emit = wrap(_emit, {
@@ -40,7 +41,7 @@ module.exports = () => {
4041
})
4142
cds.Service.prototype.handle = wrap(_handle, {
4243
wrapper: function handle(req) {
43-
if (!_tx && req.event in { BEGIN: 1, COMMIT: 1, ROLLBACK: 1 }) return _handle.apply(this, arguments)
44+
if (!_tx && req.event in _TX) return _handle.apply(this, arguments)
4445
return trace(req, _handle, this, arguments, {})
4546
}
4647
})
@@ -108,9 +109,9 @@ module.exports = () => {
108109
hanaDriver.prom = function (dbc, func) {
109110
const fn = _prom(dbc, func)
110111
dbc = dbc._parentConnection || dbc
111-
const driver = dbc.constructor.name === 'Client' ? 'hdb' : '@sap/hana-client'
112112
return function prom() {
113-
return trace(`${driver} - ${func}`, fn, this, arguments, { dbc, fn: func, kind: SpanKind.CLIENT })
113+
if (!_tx && func.toUpperCase() in _TX) return fn.apply(this, arguments)
114+
return trace(`@cap-js/hana - ${func}`, fn, this, arguments, { dbc, fn: func, kind: SpanKind.CLIENT })
114115
}
115116
}
116117
return
@@ -136,7 +137,7 @@ module.exports = () => {
136137
})
137138
dbService.prototype.exec = wrap(_exec, {
138139
wrapper: function exec(sql) {
139-
if (!_tx && sql in { BEGIN: 1, COMMIT: 1, ROLLBACK: 1 }) return _exec.apply(this, arguments)
140+
if (!_tx && sql in _TX) return _exec.apply(this, arguments)
140141
return trace(`${impl} - exec ${sql}`, _exec, this, arguments, { sql, fn: 'exec', kind: SpanKind.CLIENT })
141142
}
142143
})

lib/tracing/trace.js

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const LOG = cds.log('telemetry')
44
const otel = require('@opentelemetry/api')
55
const { SpanKind, SpanStatusCode } = otel
66
const {
7+
ATTR_SERVICE_NAME,
78
ATTR_SERVER_ADDRESS,
89
ATTR_SERVER_PORT,
910
ATTR_URL_FULL,
@@ -87,11 +88,16 @@ function _getSpanName(arg, fn, that) {
8788
return `${trgt}${phs}${event}${pth}`
8889
}
8990

90-
function _getStaticAttributes(fn) {
91+
function _getStaticAttributes(fn, options) {
9192
if (!fn[$reqattrs]) {
9293
const attributes = new Map()
9394

94-
if (fn.name) attributes.set(ATTR_CODE_FUNCTION, fn.name)
95+
if (fn.name) {
96+
attributes.set(ATTR_CODE_FUNCTION, fn.name)
97+
} else if (options?.fn) {
98+
// REVISIT: case _hana_prom = true
99+
attributes.set(ATTR_CODE_FUNCTION, options?.fn)
100+
}
95101

96102
if (fn.__location) {
97103
const location = fn.__location
@@ -163,7 +169,10 @@ function _getDynamicDBAttributes(options, args, parent) {
163169
// the second time, args is the string query -> we need to get event and target from the previous invocation (i.e., parentSpan).
164170
const dbAttributes = new Map()
165171
const db_statement =
166-
options.sql || (typeof args[0]?.query === 'string' && args[0].query) || (typeof args[0] === 'string' && args[0])
172+
options.sql ||
173+
(typeof args[0]?.query === 'string' && args[0].query) ||
174+
(typeof args[0] === 'string' && args[0]) ||
175+
parent?.attributes[ATTR_DB_STATEMENT]
167176
if (db_statement) dbAttributes.set(ATTR_DB_STATEMENT, db_statement)
168177
const db_operation = args[0]?.event || parent?.attributes[ATTR_DB_OPERATION]
169178
if (db_operation) dbAttributes.set(ATTR_DB_OPERATION, db_operation)
@@ -178,7 +187,7 @@ function _setAttributes(span, attributes) {
178187
}
179188

180189
function _addAttributes(span, fn, that, options, args, parent) {
181-
_setAttributes(span, _getStaticAttributes(fn))
190+
_setAttributes(span, _getStaticAttributes(fn, options))
182191

183192
// needed for sampling decision (cf. shouldSample)
184193
if (cds.context.http?.req) {
@@ -187,9 +196,10 @@ function _addAttributes(span, fn, that, options, args, parent) {
187196
span.setAttribute('http.target', url_path) //> http.target is deprecated
188197
}
189198

190-
if (that.dbc || options.sql) {
191-
//> NOTE: !that.dbc is the case during execution of a prepared statement
192-
199+
// NOTES:
200+
// - !that.dbc is the case during execution of a prepared statement
201+
// - with _hana_prom = true, options.sql is not there (the sql is in args)
202+
if (that.dbc || options.dbc || options.sql) {
193203
if (cds.requires.multitenancy) {
194204
const creds = that.dbc?._connection?._settings || that.dbc?._creds //> hdb vs. @sap/hana-client
195205
_setAttributes(span, _getStaticDBAttributes(cds.context?.tenant, creds))
@@ -216,14 +226,14 @@ function _addAttributes(span, fn, that, options, args, parent) {
216226
}
217227

218228
const _addDbRowCount = (span, res) => {
219-
if (!span.attributes['db.statement']) return
220-
if (!['all', 'run', 'exec'].includes(span.attributes['code.function'])) return
229+
if (!span.attributes[ATTR_DB_STATEMENT]) return
230+
if (!['all', 'run', 'exec'].includes(span.attributes[ATTR_CODE_FUNCTION])) return
221231

222232
let rowCount
223-
const operation = span.attributes['db.operation']
233+
const operation = span.attributes[ATTR_DB_OPERATION]
224234
if (operation === 'READ') rowCount = res.length ?? 1
225235
// there is no attribute for affected rows -> also set returned_rows
226-
else if (operation in { CREATE: 1, UPDATE: 1, DELETE: 1 }) rowCount = res.changes
236+
else if (operation in { CREATE: 1, UPDATE: 1, DELETE: 1 }) rowCount = res.changes ?? res
227237
if (rowCount != null) span.setAttribute('db.client.response.returned_rows', rowCount)
228238
}
229239

@@ -276,7 +286,6 @@ function trace(req, fn, that, args, opts = {}) {
276286
if (name === 'cds.spawn') name += kind === SpanKind.CONSUMER ? ' - run task' : ' - schedule task'
277287
else if (name.startsWith('messaging - emit'))
278288
name = name.replace('messaging - emit', `messaging - emit ${kind === SpanKind.CONSUMER ? 'incoming' : 'outgoing'}`)
279-
if (name.length > 80 && _truncate_span_name !== false) name = name.substring(0, 79) + '…'
280289

281290
const options = {
282291
kind,
@@ -295,6 +304,13 @@ function trace(req, fn, that, args, opts = {}) {
295304
LOG._warn && LOG.warn('Failed to determine attributes:', err)
296305
}
297306

307+
// REVISIT: with _hana_prom = true, the sql is not yet in the span name
308+
if (name.match(/^@cap-js\/\w+ - \w+$/) && options.attributes[ATTR_DB_STATEMENT]) {
309+
name += ' ' + options.attributes[ATTR_DB_STATEMENT]
310+
}
311+
312+
if (name.length > 80 && _truncate_span_name !== false) name = name.substring(0, 79) + '…'
313+
298314
// start a new active span, call the original function, and finally end the span
299315
return tracer.startActiveSpan(name, options, span => {
300316
// in case the span is non-recording, just call the original function
@@ -315,7 +331,7 @@ function trace(req, fn, that, args, opts = {}) {
315331
/* USER */ '2020202020202020202020202020202020202020202020202020202020202020'}${
316332
/* ACTION */ '20202020202020202020202020202020202020202020202020202020202020202020202020202020'}${
317333
/* ACTIONTYPE */ '0000'}${
318-
/* PREVCOMPONENTID */ Buffer.from(span.resource.attributes['service.name'].substr(0, 32).padEnd(32, ' ')).toString('hex')}${
334+
/* PREVCOMPONENTID */ Buffer.from(span.resource.attributes[ATTR_SERVICE_NAME].substr(0, 32).padEnd(32, ' ')).toString('hex')}${
319335
/* TRANSACTIONID */ Buffer.from(traceId.toUpperCase()).toString('hex')}${
320336
/* CLIENT */ '202020'}${
321337
/* COMPONENTTYPE */ '0000'}${

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cap-js/telemetry",
3-
"version": "1.4.0",
3+
"version": "1.4.1",
44
"description": "CDS plugin providing observability features, incl. automatic OpenTelemetry instrumentation.",
55
"repository": {
66
"type": "git",

test/_db_spans.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
module.exports = {
2+
SELECT: [
3+
{
4+
// name: "@cap-js/sqlite - prepare SELECT json_insert('{}','$.\"createdAt\"',createdAt,'$.\"…",
5+
name: s => s.match(/@cap-js\/\w+ - prepare /),
6+
attributes: {
7+
'code.function': 'prepare',
8+
'db.system': s => s === 'sqlite' || 'hanadb',
9+
'db.name': 'db',
10+
'db.connection_string': s => s === ':memory:' || s.match(/jdbc/),
11+
// 'db.statement':
12+
// 'SELECT json_insert(\'{}\',\'$."createdAt"\',createdAt,\'$."createdBy"\',createdBy,\'$."modifiedAt"\',modifiedAt,\'$."modifiedBy"\',modifiedBy,\'$."ID"\',ID,\'$."title"\',title,\'$."descr"\',descr,\'$."author_ID"\',author_ID,\'$."genre_ID"\',genre_ID,\'$."stock"\',stock,\'$."price"\',price,\'$."currency_code"\',currency_code) as _json_ FROM (SELECT "$B".createdAt,"$B".createdBy,"$B".modifiedAt,"$B".modifiedBy,"$B".ID,"$B".title,"$B".descr,"$B".author_ID,"$B".genre_ID,"$B".stock,"$B".price,"$B".currency_code FROM sap_capire_bookshop_Books as "$B" WHERE "$B".title is not ?)',
13+
'db.statement': s => s.match(/SELECT/),
14+
'db.operation': 'READ',
15+
'db.sql.table': 'sap.capire.bookshop.Books'
16+
}
17+
},
18+
{
19+
// name: "@cap-js/sqlite - stmt.all SELECT json_insert('{}','$.\"createdAt\"',createdAt,'$.…",
20+
name: s => s.match(/@cap-js\/\w+ - stmt\.all /) || s.match(/@cap-js\/\w+ - exec /),
21+
attributes: {
22+
'code.function': s => s === 'all' || 'exec',
23+
'db.system': s => s === 'sqlite' || 'hanadb',
24+
'db.name': 'db',
25+
'db.connection_string': s => s === ':memory:' || s.match(/jdbc/),
26+
// 'db.statement':
27+
// 'SELECT json_insert(\'{}\',\'$."createdAt"\',createdAt,\'$."createdBy"\',createdBy,\'$."modifiedAt"\',modifiedAt,\'$."modifiedBy"\',modifiedBy,\'$."ID"\',ID,\'$."title"\',title,\'$."descr"\',descr,\'$."author_ID"\',author_ID,\'$."genre_ID"\',genre_ID,\'$."stock"\',stock,\'$."price"\',price,\'$."currency_code"\',currency_code) as _json_ FROM (SELECT "$B".createdAt,"$B".createdBy,"$B".modifiedAt,"$B".modifiedBy,"$B".ID,"$B".title,"$B".descr,"$B".author_ID,"$B".genre_ID,"$B".stock,"$B".price,"$B".currency_code FROM sap_capire_bookshop_Books as "$B" WHERE "$B".title is not ?)',
28+
'db.statement': s => s.match(/SELECT/),
29+
'db.operation': 'READ',
30+
'db.sql.table': 'sap.capire.bookshop.Books',
31+
'db.client.response.returned_rows': 5
32+
}
33+
}
34+
]
35+
// TODO
36+
// INSERT: []
37+
// UPDATE: []
38+
// DELETE: []
39+
}

test/tracing-attributes.test.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,45 @@ describe('tracing attributes', () => {
99
const log = jest.spyOn(console, 'dir')
1010
beforeEach(log.mockClear)
1111

12-
describe('db.client.response.returned_rows', () => {
12+
describe('db', () => {
13+
const _db_spans = require('./_db_spans')
14+
// prettier-ignore
15+
const _get_db_spans = o => JSON.parse(o).map(o => o[0]).filter(s => !s.name.startsWith('db'))
16+
const _match_db_spans = (output, kind) => {
17+
const db_spans = _get_db_spans(output)
18+
for (const each of _db_spans[kind]) expect(db_spans).to.containSubset([each])
19+
}
20+
1321
test('SELECT', async () => {
14-
await SELECT.from('sap.capire.bookshop.Books')
22+
await SELECT.from('sap.capire.bookshop.Books').where('title !=', 'DUMMY')
1523
const output = JSON.stringify(log.mock.calls)
1624
expect(output).to.match(/db\.client\.response.returned_rows":5/)
25+
_match_db_spans(output, 'SELECT')
1726
})
1827

1928
test('INSERT', async () => {
2029
await INSERT.into('sap.capire.bookshop.Books').entries([{ ID: 1 }, { ID: 2 }])
2130
const output = JSON.stringify(log.mock.calls)
2231
expect(output).to.match(/db\.client\.response.returned_rows":2/)
32+
// TODO
33+
// _match_db_spans(output, 'INSERT')
2334
})
2435

2536
test('UPDATE', async () => {
2637
await UPDATE('sap.capire.bookshop.Books').set({ stock: 42 }).where('ID > 250')
2738
const output = JSON.stringify(log.mock.calls)
2839
expect(output).to.match(/db\.client\.response.returned_rows":3/)
40+
// TODO
41+
// _match_db_spans(output, 'UPDATE')
2942
})
3043

3144
test('DELETE', async () => {
3245
await DELETE.from('sap.capire.bookshop.Books')
3346
const output = JSON.stringify(log.mock.calls)
3447
expect(output).to.match(/db\.client\.response.returned_rows":0/) //> texts
3548
expect(output).to.match(/db\.client\.response.returned_rows":5/)
49+
// TODO
50+
// _match_db_spans(output, 'DELETE')
3651
})
3752
})
3853
})

0 commit comments

Comments
 (0)