diff --git a/lib/tracing/index.js b/lib/tracing/index.js index 7d735ddf..1fdceff9 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -178,6 +178,7 @@ module.exports = resource => { */ require('./cds')() require('./cloud_sdk')() + require('./remote')() return tracerProvider } diff --git a/lib/tracing/remote.js b/lib/tracing/remote.js new file mode 100644 index 00000000..52eb56d2 --- /dev/null +++ b/lib/tracing/remote.js @@ -0,0 +1,84 @@ +const cds = require('@sap/cds') +const LOG = cds.log('telemetry') + +const { trace } = require('@opentelemetry/api') +const { + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_FULL +} = require('@opentelemetry/semantic-conventions') + +const wrap = require('./wrap') + +function _setRequestAttributes(span, requestConfig, destination) { + if (!requestConfig) return + + const { method, url } = requestConfig + + if (method) span.setAttribute(ATTR_HTTP_REQUEST_METHOD, method) + + // build full URL from destination and request path + const baseUrl = typeof destination === 'string' ? undefined : destination?.url?.replace(/\/$/, '') + if (baseUrl) { + const fullUrl = baseUrl + (url?.startsWith('/') ? url : `/${url || ''}`) + span.setAttribute(ATTR_URL_FULL, fullUrl) + + // parse server address and port from destination URL + try { + const parsed = new URL(baseUrl) + span.setAttribute(ATTR_SERVER_ADDRESS, parsed.hostname) + const port = parsed.port || (parsed.protocol === 'https:' ? 443 : 80) + span.setAttribute(ATTR_SERVER_PORT, Number(port)) + } catch { + // ignore URL parsing errors + } + } +} + +module.exports = () => { + cds.on('served', () => { + let fetchClient + try { + fetchClient = require('@sap/cds/libx/_runtime/remote/utils/fetchClient') + } catch { + LOG._debug && LOG.debug('Could not load remote fetchClient module') + return + } + + // guard against double-wrapping + if (fetchClient.executeHttpRequest.__wrapped) return + + // wrap native fetch client + const _fetchExecute = fetchClient.executeHttpRequest + fetchClient.executeHttpRequest = wrap(_fetchExecute, { + wrapper: async function executeHttpRequest(destination, requestConfig) { + const span = trace.getActiveSpan() + + // set request attributes before the call + if (span?.isRecording()) { + try { + _setRequestAttributes(span, requestConfig, destination) + } catch (err) { + LOG._debug && LOG.debug('Failed to set HTTP request attributes:', err) + } + } + + // execute the actual request + try { + const response = await _fetchExecute.apply(this, arguments) + if (span?.isRecording() && response?.status) { + span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status) + } + return response + } catch (err) { + if (span?.isRecording() && err.response?.status) { + span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, err.response.status) + } + throw err + } + } + }) + }) +} diff --git a/test/bookshop/package.json b/test/bookshop/package.json index 92c5c2cc..5a29a748 100644 --- a/test/bookshop/package.json +++ b/test/bookshop/package.json @@ -124,5 +124,8 @@ "fiori": { "draft_deletion_timeout": false } + }, + "devDependencies": { + "@sap/cds-dk": "^9" } } diff --git a/test/tracing-attributes.test.js b/test/tracing-attributes.test.js index 24068e2c..6b90733d 100644 --- a/test/tracing-attributes.test.js +++ b/test/tracing-attributes.test.js @@ -2,6 +2,7 @@ process.env.cds_requires_telemetry_tracing_exporter_module = '@opentelemetry/sdk const cds = require('@sap/cds') const { expect, data } = cds.test().in(__dirname + '/bookshop') +const http = require('http') describe('tracing attributes', () => { beforeEach(data.reset) @@ -9,6 +10,44 @@ describe('tracing attributes', () => { const log = jest.spyOn(console, 'dir') beforeEach(log.mockClear) + describe('remote', () => { + let server, port + + beforeAll(done => { + server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ value: [] })) + }) + server.listen(0, () => { + port = server.address().port + done() + }) + }) + + afterAll(done => { + server.close(done) + }) + + test('HTTP client attributes are set on remote service span', async () => { + // skip for cds 8 due to Cloud SDK resilience module resolution issues in test environment + if (Number(cds.version.split('.')[0]) < 9) return + + // configure destination URL directly on credentials + cds.env.requires.TestRemote = { kind: 'odata', credentials: { url: `http://localhost:${port}` } } + const remote = await cds.connect.to('TestRemote') + + // no mock handler - let it make the actual HTTP call + await remote.send({ method: 'GET', path: '/test' }) + + const output = JSON.stringify(log.mock.calls) + expect(output).to.match(/"http\.request\.method":"GET"/) + expect(output).to.match(/"http\.response\.status_code":200/) + expect(output).to.match(new RegExp(`"url\\.full":"http://localhost:${port}/test"`)) + expect(output).to.match(/"server\.address":"localhost"/) + expect(output).to.match(new RegExp(`"server\\.port":${port}`)) + }) + }) + describe('db', () => { const _db_spans = require('./_db_spans') // prettier-ignore