Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/tracing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ module.exports = resource => {
*/
require('./cds')()
require('./cloud_sdk')()
require('./remote')()

return tracerProvider
}
88 changes: 88 additions & 0 deletions lib/tracing/remote.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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
Comment thread
vkozyura marked this conversation as resolved.

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(/\/$/, '')
Comment thread
vkozyura marked this conversation as resolved.
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)
Comment thread
vkozyura marked this conversation as resolved.
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
Comment thread
vkozyura marked this conversation as resolved.
Comment thread
vkozyura marked this conversation as resolved.

// wrap native fetch client
const _fetchExecute = fetchClient.executeHttpRequest
fetchClient.executeHttpRequest = wrap(_fetchExecute, {
Comment thread
vkozyura marked this conversation as resolved.
wrapper: async function executeHttpRequest(destination, requestConfig) {
const span = trace.getActiveSpan()

// set request attributes before the call
if (span?.isRecording()) {
try {
_setRequestAttributes(span, requestConfig, destination)
Comment thread
vkozyura marked this conversation as resolved.
} catch (err) {
LOG._debug && LOG.debug('Failed to set HTTP request attributes:', err)
}
}

// execute the actual request
let response
try {
response = await _fetchExecute.apply(this, arguments)
Comment thread
vkozyura marked this conversation as resolved.
Outdated
Comment thread
vkozyura marked this conversation as resolved.
Outdated
return response
} catch (err) {
// set error status code from error response
if (span?.isRecording() && err.response?.status) {
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, err.response.status)
}
throw err
} finally {
// set success status code
if (span?.isRecording() && response?.status) {
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status)
}
}
Comment thread
vkozyura marked this conversation as resolved.
Outdated
Comment thread
vkozyura marked this conversation as resolved.
Outdated
}
})
})
}
3 changes: 3 additions & 0 deletions test/bookshop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,8 @@
"fiori": {
"draft_deletion_timeout": false
}
},
"devDependencies": {
"@sap/cds-dk": "^9"
}
}
39 changes: 39 additions & 0 deletions test/tracing-attributes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,52 @@ 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)

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
Expand Down
Loading