From 7d7a672712605b2ab59e8e3039af73ac88660159 Mon Sep 17 00:00:00 2001 From: D050513 Date: Thu, 25 Jun 2026 21:08:48 +0200 Subject: [PATCH 01/13] prep v2.0.0 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 828369f0..ba05b910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). The format is based on [Keep a Changelog](http://keepachangelog.com/). -## Version 1.7.0 - tbd +## Version 2.0.0 - tbd ### Added diff --git a/package.json b/package.json index 03583029..7bbba53a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cap-js/telemetry", - "version": "1.6.0", + "version": "2.0.0", "description": "CDS plugin providing observability features, incl. automatic OpenTelemetry instrumentation.", "repository": { "type": "git", From 3fdad54269d33e7585cec95e2bb0b6aefb176559 Mon Sep 17 00:00:00 2001 From: D050513 Date: Thu, 25 Jun 2026 21:13:30 +0200 Subject: [PATCH 02/13] CI --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f918293..bbb682ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, 2] env: NPM_CONFIG_IGNORE_SCRIPTS: true @@ -26,7 +26,8 @@ jobs: fail-fast: false matrix: node-version: [24.x, 22.x] - cds-version: [9, 8] + # cds-version: [10, 9] + cds-version: [9] steps: - uses: actions/checkout@v6.0.3 - name: Use Node.js ${{ matrix.node-version }} @@ -35,7 +36,7 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm i -g @sap/cds-dk@${{ matrix.cds-version }} - run: npm i - - run: if [ ${{ matrix.cds-version }} -eq 8 ]; then npm i -f @sap/cds@8 @cap-js/sqlite@1 @sap/cds-mtxs@2; fi - - run: npm explore better-sqlite3 -- npm run install + - run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm i -f @sap/cds@8 @cap-js/sqlite@2 @sap/cds-mtxs@3; fi + #- run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm explore better-sqlite3 -- npm run install; fi - run: cds v - run: npm run test From a785be41bbc29a8ad7a483f013d76176b8e08656 Mon Sep 17 00:00:00 2001 From: D050513 Date: Thu, 25 Jun 2026 21:19:21 +0200 Subject: [PATCH 03/13] npm explore better-sqlite3 -- npm run install --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbb682ff..d44def50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,6 @@ jobs: - run: npm i -g @sap/cds-dk@${{ matrix.cds-version }} - run: npm i - run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm i -f @sap/cds@8 @cap-js/sqlite@2 @sap/cds-mtxs@3; fi - #- run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm explore better-sqlite3 -- npm run install; fi + - run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm explore better-sqlite3 -- npm run install; fi - run: cds v - run: npm run test From 419698e8cc7324a004f6988e7f51160ff77e15df Mon Sep 17 00:00:00 2001 From: Vitaly Kozyura <58591662+vkozyura@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:15:49 +0200 Subject: [PATCH 04/13] feat: add HTTP client attributes to remote service (#425) Adds HTTP semantic convention attributes to remote service spans by wrapping `fetchClient.executeHttpRequest`. Alternative considered: Using `@opentelemetry/instrumentation-undici` response hooks to propagate attributes from HTTP child spans. This approach was rejected as it requires an additional dependency and creates duplicate spans. --------- Co-authored-by: D050513 Co-authored-by: sjvans <30337871+sjvans@users.noreply.github.com> --- CHANGELOG.md | 2 ++ README.md | 4 ++++ package.json | 5 +++++ test/tracing-attributes.test.js | 39 +++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba05b910..187f63e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Added +- `@opentelemetry/instrumentation-undici` added to the list of default instrumentations + ### Changed ### Fixed diff --git a/README.md b/README.md index 0da2ebf1..7111c000 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,10 @@ Default: "http": { "module": "@opentelemetry/instrumentation-http", "class": "HttpInstrumentation" + }, + "undici": { + "module": "@opentelemetry/instrumentation-undici", + "class": "UndiciInstrumentation" } } ``` diff --git a/package.json b/package.json index 7bbba53a..7fedc8c4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@opentelemetry/core": "^1.30", "@opentelemetry/instrumentation": "^0.57", "@opentelemetry/instrumentation-http": "^0.57", + "@opentelemetry/instrumentation-undici": "^0.29", "@opentelemetry/resources": "^1.30", "@opentelemetry/sdk-metrics": "^1.30", "@opentelemetry/sdk-trace-base": "^1.30", @@ -61,6 +62,10 @@ "http": { "module": "@opentelemetry/instrumentation-http", "class": "HttpInstrumentation" + }, + "undici": { + "module": "@opentelemetry/instrumentation-undici", + "class": "UndiciInstrumentation" } }, "tracing": { 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 From b8f5c0cccaa5a28510f205f416911c1428f179eb Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:20:06 +0200 Subject: [PATCH 05/13] feat: support OpenTelemetry SDK 2.0 (#364) - [x] traces - [x] metrics - [x] logs - [x] https://github.com/cap-js/telemetry/pull/442 --------- Co-authored-by: I548646 Co-authored-by: Paul --- .gitignore | 2 + CHANGELOG.md | 3 + README.md | 14 +--- cds-plugin.js | 36 --------- lib/exporter/ConsoleMetricExporter.js | 2 +- lib/exporter/ConsoleSpanExporter.js | 6 +- lib/index.js | 57 +++++++++---- lib/logging/index.js | 62 +++++++++------ lib/metrics/host.js | 14 ---- lib/metrics/index.js | 110 +++++++++++++------------- lib/tracing/index.js | 74 +++++++++-------- lib/tracing/trace.js | 2 +- lib/utils.js | 17 ++-- package.json | 28 +++---- test/bookshop/package.json | 9 +-- 15 files changed, 219 insertions(+), 217 deletions(-) delete mode 100644 lib/metrics/host.js diff --git a/.gitignore b/.gitignore index 9b0ce94b..dc23330c 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,5 @@ dist test/msg-box test/bookshop/gen test/bookshop/.cdsrc.json + +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 187f63e0..2bb92460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Added +- Support for OpenTelemetry SDK 2.0 - `@opentelemetry/instrumentation-undici` added to the list of default instrumentations ### Changed +- `@opentelemetry/instrumentation-host-metrics` replaces deprecated `@opentelemetry/host-metrics` + ### Fixed - Prefer `VCAP_APPLICATION.name` over `name` in `package.json` for app name resolution diff --git a/README.md b/README.md index 7111c000..02c68cc2 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,6 @@ -> [!WARNING] -> [OpenTelemetry SDK 2.0](https://github.com/open-telemetry/opentelemetry-js/releases/tag/v2.0.0) is not yet supported. - - - ## About This Project `@cap-js/telemetry` is a CDS plugin providing observability features, including [automatic OpenTelemetry instrumentation](https://opentelemetry.io/docs/concepts/instrumentation/automatic). @@ -108,17 +103,14 @@ In environments where Dynatrace OneAgent is installed (e.g., SAP BTP CF), no Ope Metrics are "measurements captured at runtime", which help you understand your app's health and performance. The `@cap-js/telemetry` enables the observation of some metrics out of the box. -These include generic [`@opentelemetry/host-metrics`](https://www.npmjs.com/package/@opentelemetry/host-metrics) (if the package is found in the app's dependencies), +These include generic host metrics collected by [`@opentelemetry/instrumentation-host-metrics`](https://www.npmjs.com/package/@opentelemetry/instrumentation-host-metrics) (if the package is found in the app's dependencies), metrics regarding the app's database pool, namely the [pool info](https://www.npmjs.com/package/generic-pool#pool-info) statistics of `generic-pool`, as well as metrics regarding [CAP's Persistent Queue](https://cap.cloud.sap/docs/node.js/queue#persistent-queue). #### Host Metrics -Currently, there is no public config option to influence which metrics `@opentelemetry/host-metrics` collects. -However, it is possible to instruct the meter provider during initialization, which metrics shall be ignored. -By default, this is done for all `system.*` metrics collected by `@opentelemetry/host-metrics`. -This can be disabled via environment variable `HOST_METRICS_RETAIN_SYSTEM=true`. -As these so-called *views* must be passed into the constructor, the above only applies in case `@cap-js/telemetry` initializes the meter provider. +[@opentelemetry/instrumentation-host-metrics](https://www.npmjs.com/package/@opentelemetry/instrumentation-host-metrics) allows you to optionally restrict collection to one or more metric groups via config option `metricGroups`. +For backward compatibility, `@cap-js/telemetry` limits collection to `['process.cpu', 'process.memory']` if not configured manually (via [`cds.requires.telemetry.instrumentations`](#instrumentations)) or disabled via environment variable `HOST_METRICS_RETAIN_SYSTEM=true`. To avoid spamming the console, only `process.*` metrics are printed by default, regardless of whether the `system.*` metrics are ignored or not. Printing the `system.*` metrics (if not ignored) in the built-in console exporter can be enabled via environment variable `HOST_METRICS_LOG_SYSTEM=true`. diff --git a/cds-plugin.js b/cds-plugin.js index a3b091f2..1704e142 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -8,41 +8,5 @@ if (!!process.env.NO_TELEMETRY && process.env.NO_TELEMETRY !== 'false') return - const _version_of = module => { - let pkg - try { - pkg = require(`${module}/package.json`) - } catch { - try { - const path = require.resolve(module).split(module)[0] + module + '/package.json' - pkg = JSON.parse(require('fs').readFileSync(path, 'utf-8')) - } catch { - // ignore - } - } - if (!pkg) { - cds.log('telemetry').warn(`Unable to determine version of ${module}`) - return - } - return pkg.version - } - - // check versions of @opentelemetry dependencies - const { dependencies } = require(require('path').join(cds.root, 'package')) - let violations = [] - for (const each in dependencies) { - if (!each.match(/^@opentelemetry\//)) continue - const version = _version_of(each) - if (!version) continue - const [major, minor] = version.split('.') - if (major >= 2 || minor >= 200) violations.push(`${each}@${version}`) - } - if (violations.length) { - const msg = - '@cap-js/telemetry does not yet support OpenTelemetry SDK 2.0 (^2 and ^0.200):' + - `\n - ${violations.join('\n - ')}\n` - throw new Error(msg) - } - require('./lib')() })() diff --git a/lib/exporter/ConsoleMetricExporter.js b/lib/exporter/ConsoleMetricExporter.js index e04b8cec..21493a3d 100644 --- a/lib/exporter/ConsoleMetricExporter.js +++ b/lib/exporter/ConsoleMetricExporter.js @@ -15,7 +15,7 @@ class ConsoleMetricExporter extends StandardConsoleMetricExporter { } for (const scopeMetrics of metrics.scopeMetrics) { - if (scopeMetrics.scope.name.endsWith(':host-metrics')) { + if (scopeMetrics.scope.name === '@opentelemetry/instrumentation-host-metrics') { // aggregate host metrics const collector = {} for (const metric of scopeMetrics.metrics) { diff --git a/lib/exporter/ConsoleSpanExporter.js b/lib/exporter/ConsoleSpanExporter.js index 023ae9f5..1564be74 100644 --- a/lib/exporter/ConsoleSpanExporter.js +++ b/lib/exporter/ConsoleSpanExporter.js @@ -58,7 +58,7 @@ const _span_sorter = (a, b) => { const _list2tree = (span, spans, flat, indent) => { const spanId = span.spanContext().spanId - const children = spans.filter(s => s.parentSpanId === spanId) + const children = spans.filter(s => s.parentSpanContext?.spanId === spanId) if (children.length === 0) return children.sort(_span_sorter) for (const each of children) { @@ -100,13 +100,13 @@ class ConsoleSpanExporter /* implements SpanExporter */ { _sendSpans(spans, done) { for (const span of spans) { const w3c_parent_id = cds.context?.http?.req.headers.traceparent?.split('-')[2] - if (!span.parentSpanId || span.parentSpanId === w3c_parent_id) { + if (!span.parentSpanContext?.spanId || span.parentSpanContext?.spanId === w3c_parent_id) { let toLog = 'elapsed times:' toLog += _span2line(span) const children = this._temporaryStorage.get(span.spanContext().traceId) if (children) { const ids = new Set(children.map(s => s.spanContext().spanId).filter(s => !!s)) - const reqs = children.filter(s => s.spanContext().spanId && !ids.has(s.parentSpanId)) + const reqs = children.filter(s => s.spanContext().spanId && !ids.has(s.parentSpanContext?.spanId)) const flat = [] reqs.sort(_span_sorter) for (const each of reqs) { diff --git a/lib/index.js b/lib/index.js index 35dc17f2..18a7b3cc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -13,7 +13,7 @@ const { registerInstrumentations } = require('@opentelemetry/instrumentation') const tracing = require('./tracing') const metrics = require('./metrics') const logging = require('./logging') -const { getDiagLogLevel, getResource, _require } = require('./utils') +const { getDiagLogLevel, getResource, hasDependency, _require } = require('./utils') function _getInstrumentations() { const _instrumentations = cds.env.requires.telemetry.instrumentations @@ -36,6 +36,33 @@ function _getInstrumentations() { } } + // if @opentelemetry/instrumentation-host-metrics is in project's dependencies but not in cds.env.requires.telemetry.instrumentations, add it automatically + if ( + !Object.keys(_instrumentations).includes('instrumentation-host-metrics') && + !Object.values(_instrumentations).find(i => i?.module === '@opentelemetry/instrumentation-host-metrics') + ) { + try { + const pkg = require(require('path').join(cds.root, 'package')) + if (Object.keys(pkg.dependencies).includes('@opentelemetry/instrumentation-host-metrics')) { + _instrumentations['instrumentation-host-metrics'] = { + class: 'HostMetricsInstrumentation', + module: '@opentelemetry/instrumentation-host-metrics' + } + } + } catch (err) { + LOG._debug && LOG.debug('Failed to automatically add @opentelemetry/instrumentation-host-metrics:', err) + } + } + + // by default, all `system.*` metrics shall be ignored + const host_metrics = Object.values(_instrumentations).find( + i => i?.module === '@opentelemetry/instrumentation-host-metrics' + ) + if (host_metrics && !host_metrics.config?.metricGroups && !process.env.HOST_METRICS_RETAIN_SYSTEM) { + host_metrics.config ??= {} + host_metrics.config.metricGroups = ['process.cpu', 'process.memory'] + } + const instrumentations = [] for (const each of Object.values(_instrumentations)) { if (!each) continue //> could be falsy @@ -62,30 +89,19 @@ function _getInstrumentations() { return instrumentations } -module.exports = function () { +function setup_standalone() { // set logger and propagate log level diag.setLogger(cds.log('telemetry'), getDiagLogLevel()) + // create resource const resource = getResource() - /* - * setup tracing - */ + // setup tracing, metrics, and logging const tracerProvider = tracing(resource) - - /* - * setup metrics - */ const meterProvider = metrics(resource) - - /* - * setup logging - */ const loggerProvider = cds.env.requires.telemetry.logging ? logging(resource) : undefined - /* - * register instrumentations - */ + // register instrumentations registerInstrumentations({ tracerProvider, meterProvider, @@ -93,3 +109,12 @@ module.exports = function () { instrumentations: _getInstrumentations() }) } + +function setup_with_calm() { + // setup tracing, metrics, and logging + tracing() + metrics() + if (cds.env.requires.telemetry.logging) logging() +} + +module.exports = hasDependency('@sap/xotel-agent-ext-js') ? setup_with_calm : setup_standalone diff --git a/lib/logging/index.js b/lib/logging/index.js index bed6f3f1..6654a1c3 100644 --- a/lib/logging/index.js +++ b/lib/logging/index.js @@ -1,7 +1,7 @@ const cds = require('@sap/cds') const LOG = cds.log('telemetry') -const { getEnv, getEnvWithoutDefaults } = require('@opentelemetry/core') +const { getStringFromEnv } = require('@opentelemetry/core') const { getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') @@ -20,15 +20,15 @@ function _getExporter() { // for kind telemetry-to-otlp based on env vars if (loggingExporter === 'env') { - const cstm_env = getEnvWithoutDefaults() - const otlp_env = getEnv() - let protocol = cstm_env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL ?? cstm_env.OTEL_EXPORTER_OTLP_PROTOCOL + let protocol = + getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') // on kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default if (!protocol) { - const endpoint = otlp_env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? otlp_env.OTEL_EXPORTER_OTLP_ENDPOINT ?? '' + const endpoint = + getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '' if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= otlp_env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL ?? otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL + protocol ??= getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') loggingExporter = { module: _protocol2module[protocol], class: 'OTLPLogExporter' } } @@ -76,24 +76,10 @@ module.exports = resource => { const { logs, SeverityNumber } = require('@opentelemetry/api-logs') const { LoggerProvider, BatchLogRecordProcessor, SimpleLogRecordProcessor } = require('@opentelemetry/sdk-logs') - let loggerProvider = logs.getLoggerProvider() - if (loggerProvider.constructor.name === 'ProxyLoggerProvider') { - loggerProvider = new LoggerProvider({ resource }) - logs.setGlobalLoggerProvider(loggerProvider) - } else { - LOG._warn && LOG.warn('LoggerProvider already initialized by a different module. It will be used as is.') - } - - const exporter = _getExporter() - - const logProcessor = - _getCustomProcessor(exporter) || - (process.env.NODE_ENV === 'production' - ? new BatchLogRecordProcessor(exporter) - : new SimpleLogRecordProcessor(exporter)) - loggerProvider.addLogRecordProcessor(logProcessor) - + // setup logs interception via cds.log.format cds.on('served', () => { + const loggerProvider = logs.getLoggerProvider() + const loggers = {} const l2s = { 1: 'ERROR', 2: 'WARN', 3: 'INFO', 4: 'DEBUG', 5: 'TRACE' } @@ -141,5 +127,35 @@ module.exports = resource => { for (const each in cds.log.loggers) cds.log.loggers[each].setFormat(format) }) + /* + * create processor + */ + const exporter = _getExporter() + const processor = + _getCustomProcessor(exporter) || + (process.env.NODE_ENV === 'production' + ? new BatchLogRecordProcessor(exporter) + : new SimpleLogRecordProcessor(exporter)) + + /* + * either add processor as delegate in CALM... + */ + if (!resource) { + LOG.warn("@sap/xotel-agent-ext-js found, adding @cap-js/telemetry's log processor as delegate") + try { + const { getCompositeLogRecordProcessor } = require('@sap/xotel-agent-ext-js') + getCompositeLogRecordProcessor().addDelegate(processor) + return + } catch (error) { + LOG.error('Failed to add log processor as delegate:', error) + throw error + } + } + + /* + * ... or initialize and return provider + */ + const loggerProvider = new LoggerProvider({ resource, processors: [processor] }) + logs.setGlobalLoggerProvider(loggerProvider) return loggerProvider } diff --git a/lib/metrics/host.js b/lib/metrics/host.js deleted file mode 100644 index e72885ed..00000000 --- a/lib/metrics/host.js +++ /dev/null @@ -1,14 +0,0 @@ -const { metrics } = require('@opentelemetry/api') - -const { hasDependency } = require('../utils') - -module.exports = () => { - if (!hasDependency('@opentelemetry/host-metrics')) return - - const { HostMetrics } = require('@opentelemetry/host-metrics') - const hostMetrics = new HostMetrics({ - meterProvider: metrics.getMeterProvider(), //> technically not needed but otherwise we get a warning - name: '@cap-js/telemetry:host-metrics' //> REVISIT: what shall this name be? - }) - hostMetrics.start() -} diff --git a/lib/metrics/index.js b/lib/metrics/index.js index 28c001f8..4eba5620 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -2,14 +2,13 @@ const cds = require('@sap/cds') const LOG = cds.log('telemetry') const { metrics } = require('@opentelemetry/api') -const { getEnv, getEnvWithoutDefaults } = require('@opentelemetry/core') -const { Resource } = require('@opentelemetry/resources') +const { getStringFromEnv } = require('@opentelemetry/core') +const { resourceFromAttributes } = require('@opentelemetry/resources') const { AggregationTemporality, - DropAggregation, + AggregationType, MeterProvider, - PeriodicExportingMetricReader, - View + PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics') const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') @@ -27,55 +26,63 @@ function _getExporter() { credentials } = cds.env.requires.telemetry - // for kind telemetry-to-otlp based on env vars if (metricsExporter === 'env') { - const cstm_env = getEnvWithoutDefaults() - const otlp_env = getEnv() - let protocol = cstm_env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? cstm_env.OTEL_EXPORTER_OTLP_PROTOCOL - // on kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default + // ... process env to determine exporter module to use + let protocol = + getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') + if (!protocol) { - const endpoint = otlp_env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? otlp_env.OTEL_EXPORTER_OTLP_ENDPOINT ?? '' + // > On kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default + const endpoint = + getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '' if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= otlp_env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL + + protocol ??= + getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') metricsExporter = { module: _protocol2module[protocol], class: 'OTLPMetricExporter' } } - // use _require for better error message + // Import the configured exporter module > use _require for better error message const metricsExporterModule = metricsExporter.module === '@cap-js/telemetry' ? require('../exporter') : _require(metricsExporter.module) if (!metricsExporterModule[metricsExporter.class]) throw new Error(`Unknown metrics exporter "${metricsExporter.class}" in module "${metricsExporter.module}"`) + const config = { ...(metricsExporter.config || {}) } + config.temporalityPreference ??= AggregationTemporality.DELTA + // Augment configuration depending on 'kind' of telemetry if (kind.match(/to-dynatrace$/)) { if (!credentials) credentials = getCredsForDTAsUPS() if (!credentials) throw new Error('No Dynatrace credentials found.') + config.url ??= `${credentials.apiurl}/v2/otlp/v1/metrics` config.headers ??= {} - // credentials.rest_apitoken?.token is deprecated and only supported for compatibility reasons + + // Extract REST API token from credentials to configure auth: + // > 'metrics_apitoken' for compatibility with previous releases + // > 'credentials.rest_apitoken?.token' is deprecated and only supported for compatibility reasons const { token_name } = cds.env.requires.telemetry - // metrics_apitoken for compatibility with previous releases const token = credentials[token_name] || credentials.metrics_apitoken || credentials.rest_apitoken?.token if (!token) throw new Error(`Neither "${token_name}" nor deprecated "rest_apitoken.token" found in Dynatrace credentials`) + config.headers.authorization ??= `Api-Token ${token}` } if (kind.match(/to-cloud-logging$/)) { if (!credentials) credentials = getCredsForCLSAsUPS() if (!credentials) throw new Error('No SAP Cloud Logging credentials found.') + augmentCLCreds(credentials) + config.url ??= credentials.url config.credentials ??= credentials.credentials } - // default to DELTA - config.temporalityPreference ??= AggregationTemporality.DELTA - const exporter = new metricsExporterModule[metricsExporter.class](config) LOG._debug && LOG.debug('Using metrics exporter:', exporter) - return exporter } @@ -83,48 +90,43 @@ module.exports = resource => { if (!cds.env.requires.telemetry.metrics?.exporter) return /* - * general setup + * add individual metrics */ - let meterProvider = metrics.getMeterProvider() - if (meterProvider.constructor.name === 'NoopMeterProvider') { - const dtmetadata = getDynatraceMetadata() - resource = new Resource({}).merge(resource).merge(dtmetadata) - // unfortunately, we have to pass views to the MeterProvider constructor - // something like meterProvider.addView() would be a lot nicer for locality - let views = [] - if (process.env.HOST_METRICS_RETAIN_SYSTEM) { - // nothing to do - } else { - views.push( - new View({ - meterName: '@cap-js/telemetry:host-metrics', - instrumentName: 'system.*', - aggregation: new DropAggregation() - }) - ) - } - meterProvider = new MeterProvider({ resource, views }) - metrics.setGlobalMeterProvider(meterProvider) - } else { - LOG._warn && LOG.warn('MeterProvider already initialized by a different module. It will be used as is.') - } + require('./db-pool')() + require('./queue')() + /* + * create reader + */ const metricsConfig = cds.env.requires.telemetry.metrics.config - const exporter = _getExporter() - // push vs. pull - if (typeof exporter.export === 'function') { - const metricReader = new PeriodicExportingMetricReader({ ...metricsConfig, exporter }) - meterProvider.addMetricReader(metricReader) - } else { - meterProvider.addMetricReader(exporter) + let reader = _getExporter() + if (typeof reader.export === 'function') { + // In case export is a function to be called by this runtime (push): + // > The exporter needs to be wrapped thus, to set an export interval + reader = new PeriodicExportingMetricReader({ ...metricsConfig, exporter: reader }) } /* - * add individual metrics + * either add reader as delegate in CALM... */ - require('./db-pool')() - require('./queue')() - require('./host')() + if (!resource) { + LOG.warn("@sap/xotel-agent-ext-js found, adding @cap-js/telemetry's metric reader as delegate") + try { + const { getCompositeMetricReader } = require('@sap/xotel-agent-ext-js') + getCompositeMetricReader().addDelegate(reader) + return + } catch (error) { + LOG.error('Failed to add metric reader as delegate:', error) + throw error + } + } + /* + * ... or initialize and return provider + */ + const dtmetadata = getDynatraceMetadata() + resource = resourceFromAttributes({}).merge(resource).merge(dtmetadata) + const meterProvider = new MeterProvider({ resource, readers: [reader] }) + metrics.setGlobalMeterProvider(meterProvider) return meterProvider } diff --git a/lib/tracing/index.js b/lib/tracing/index.js index 7d735ddf..fcb77703 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -2,8 +2,8 @@ const cds = require('@sap/cds') const LOG = cds.log('telemetry') const { trace } = require('@opentelemetry/api') -const { getEnv, getEnvWithoutDefaults } = require('@opentelemetry/core') -const { Resource } = require('@opentelemetry/resources') +const { getStringFromEnv } = require('@opentelemetry/core') +const { resourceFromAttributes } = require('@opentelemetry/resources') const { BatchSpanProcessor, SimpleSpanProcessor, SamplingDecision } = require('@opentelemetry/sdk-trace-base') const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node') @@ -85,15 +85,16 @@ function _getExporter() { // for kind telemetry-to-otlp based on env vars if (tracingExporter === 'env') { - const cstm_env = getEnvWithoutDefaults() - const otlp_env = getEnv() - let protocol = cstm_env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? cstm_env.OTEL_EXPORTER_OTLP_PROTOCOL + let protocol = + getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') // on kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default if (!protocol) { - const endpoint = otlp_env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? otlp_env.OTEL_EXPORTER_OTLP_ENDPOINT ?? '' + const endpoint = + getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '' if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= otlp_env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL + protocol ??= + getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') tracingExporter = { module: _protocol2module[protocol], class: 'OTLPTraceExporter' } } @@ -134,19 +135,25 @@ function _getExporter() { module.exports = resource => { if (!cds.env.requires.telemetry.tracing?.exporter) return + // clear sap passport for new tx + if (process.env.SAP_PASSPORT) { + cds.on('served', () => { + cds.db?.before('BEGIN', async function () { + if (this.dbc?.constructor.name in { HDBDriver: 1, HANAClientDriver: 1 }) this.dbc.set({ SAP_PASSPORT: '' }) + }) + }) + } + /* - * general setup + * add tracing */ - let tracerProvider = trace.getTracerProvider() - if (!tracerProvider.getDelegateTracer()) { - const dtmetadata = getDynatraceMetadata() - resource = new Resource({}).merge(resource).merge(dtmetadata) - tracerProvider = new NodeTracerProvider({ resource, sampler: _getSampler() }) - tracerProvider.register({ propagator: _getPropagator() }) - } else { - LOG._warn && LOG.warn('TracerProvider already initialized by a different module. It will be used as is.') - tracerProvider = tracerProvider.getDelegate() - } + require('./cds')() + require('./cloud_sdk')() + + /* + * create processor + */ + let processor const via_one_agent = process.env.DT_NODE_PRELOAD_OPTIONS && cds.env.requires.telemetry.kind.match(/to-dynatrace$/) && @@ -157,27 +164,32 @@ module.exports = resource => { } else { const exporter = _getExporter() const processorConfig = cds.env.requires.telemetry.tracing.processor?.config || {} - const processor = + processor = process.env.NODE_ENV === 'production' ? new BatchSpanProcessor(exporter, processorConfig) : new SimpleSpanProcessor(exporter, processorConfig) - tracerProvider.addSpanProcessor(processor) } - // clear sap passport for new tx - if (process.env.SAP_PASSPORT) { - cds.on('served', () => { - cds.db?.before('BEGIN', async function () { - if (this.dbc?.constructor.name in { HDBDriver: 1, HANAClientDriver: 1 }) this.dbc.set({ SAP_PASSPORT: '' }) - }) - }) + /* + * either add processor as delegate in CALM... + */ + if (!resource) { + LOG.warn("@sap/xotel-agent-ext-js found, adding @cap-js/telemetry's span processor as delegate") + try { + const { getCompositeSpanProcessor } = require('@sap/xotel-agent-ext-js') + getCompositeSpanProcessor().addDelegate(processor) + return + } catch (error) { + LOG.error('Failed to add span processor as delegate:', error) + throw error + } } /* - * add tracing + * ... or initialize and return provider */ - require('./cds')() - require('./cloud_sdk')() - + resource = resourceFromAttributes({}).merge(resource).merge(getDynatraceMetadata()) + const tracerProvider = new NodeTracerProvider({ resource, spanProcessors: [processor], sampler: _getSampler() }) + tracerProvider.register({ propagator: _getPropagator() }) return tracerProvider } diff --git a/lib/tracing/trace.js b/lib/tracing/trace.js index 9cc3b120..f7914446 100644 --- a/lib/tracing/trace.js +++ b/lib/tracing/trace.js @@ -278,7 +278,7 @@ function trace(req, fn, that, args, opts = {}) { if (!root && parent?.isRecording() === false) return fn.apply(that, args) // augment root span with request attributes, overwrite start time, and adjust root name - if (parent?.instrumentationLibrary?.name === '@opentelemetry/instrumentation-http' && !parent[$adjusted]) { + if (parent?.instrumentationScope?.name === '@opentelemetry/instrumentation-http' && !parent[$adjusted]) { parent[$adjusted] = true _setAttributes(parent, _getRequestAttributes()) const ctx = cds.context diff --git a/lib/utils.js b/lib/utils.js index 1c68d306..33c099ca 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,8 +4,8 @@ const LOG = cds.log('telemetry') const fs = require('fs') const { DiagLogLevel } = require('@opentelemetry/api') -const { hrTimeToMilliseconds } = require('@opentelemetry/core') -const { Resource } = require('@opentelemetry/resources') +const { hrTimeToMilliseconds, getStringFromEnv } = require('@opentelemetry/core') +const { resourceFromAttributes } = require('@opentelemetry/resources') const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, @@ -46,11 +46,12 @@ function getResource() { const attributes = {} // Service - attributes[ATTR_SERVICE_NAME] = process.env.OTEL_SERVICE_NAME || name - attributes[ATTR_SERVICE_VERSION] = process.env.OTEL_SERVICE_VERSION || version + attributes[ATTR_SERVICE_NAME] = getStringFromEnv('OTEL_SERVICE_NAME') || name + attributes[ATTR_SERVICE_VERSION] = getStringFromEnv('OTEL_SERVICE_VERSION') || version // Service (Experimental) - if (process.env.OTEL_SERVICE_NAMESPACE) attributes[ATTR_SERVICE_NAMESPACE] = process.env.OTEL_SERVICE_NAMESPACE + if (getStringFromEnv('OTEL_SERVICE_NAMESPACE')) + attributes[ATTR_SERVICE_NAMESPACE] = getStringFromEnv('OTEL_SERVICE_NAMESPACE') if (VCAP_APPLICATION) attributes[ATTR_SERVICE_INSTANCE_ID] = VCAP_APPLICATION.instance_id if (process.env.CF_INSTANCE_GUID) { @@ -75,14 +76,14 @@ function getResource() { if (cds.env.requires.telemetry?.resource?.attributes) Object.assign(attributes, cds.env.requires.telemetry.resource.attributes) - return new Resource(attributes) + return resourceFromAttributes(attributes) } let dtmetadata function getDynatraceMetadata() { if (dtmetadata) return dtmetadata - dtmetadata = new Resource({}) + dtmetadata = resourceFromAttributes({}) for (let name of [ 'dt_metadata_e617c525669e072eebe3d0f08212e8f2.json', '/var/lib/dynatrace/enrichment/dt_metadata.json' @@ -93,7 +94,7 @@ function getDynatraceMetadata() { .readFileSync(name.startsWith('/var') ? name : fs.readFileSync(name).toString('utf-8').trim()) .toString('utf-8') LOG._debug && LOG.debug('Successful') - dtmetadata = dtmetadata.merge(new Resource(JSON.parse(content))) + dtmetadata = dtmetadata.merge(resourceFromAttributes(JSON.parse(content))) break } catch (err) { LOG._debug && LOG.debug('Failed with error:', err) diff --git a/package.json b/package.json index 7fedc8c4..5b446cae 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,15 @@ }, "dependencies": { "@opentelemetry/api": "^1.9", - "@opentelemetry/core": "^1.30", - "@opentelemetry/instrumentation": "^0.57", - "@opentelemetry/instrumentation-http": "^0.57", + "@opentelemetry/core": "^2.8", + "@opentelemetry/instrumentation": "^0.219", + "@opentelemetry/instrumentation-http": "^0.219", "@opentelemetry/instrumentation-undici": "^0.29", - "@opentelemetry/resources": "^1.30", - "@opentelemetry/sdk-metrics": "^1.30", - "@opentelemetry/sdk-trace-base": "^1.30", - "@opentelemetry/sdk-trace-node": "^1.30", - "@opentelemetry/semantic-conventions": "^1.36" + "@opentelemetry/resources": "^2.8", + "@opentelemetry/sdk-metrics": "^2.8", + "@opentelemetry/sdk-trace-base": "^2.8", + "@opentelemetry/sdk-trace-node": "^2.8", + "@opentelemetry/semantic-conventions": "^1.41" }, "peerDependencies": { "@sap/cds": "^8 || ^9" @@ -37,12 +37,12 @@ "@cap-js/sqlite": "^1 || ^2", "@cap-js/telemetry": "file:.", "@grpc/grpc-js": "^1.9.14", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.57.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.57.0", - "@opentelemetry/host-metrics": "^0.36.0", - "@opentelemetry/instrumentation-runtime-node": "^0.17.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.219", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.219", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.219", + "@opentelemetry/exporter-trace-otlp-proto": "^0.219", + "@opentelemetry/instrumentation-host-metrics": "^0.2.0", + "@opentelemetry/instrumentation-runtime-node": "^0.32.0", "@sap/cds-mtxs": "^2 || ^3", "axios": "^1.6.7", "chai": "^4.4.1", diff --git a/test/bookshop/package.json b/test/bookshop/package.json index 92c5c2cc..f5a04c2c 100644 --- a/test/bookshop/package.json +++ b/test/bookshop/package.json @@ -3,13 +3,16 @@ "dependencies": { "@cap-js/sqlite": "*", "@cap-js/telemetry": "*", - "@opentelemetry/host-metrics": "*", + "@opentelemetry/instrumentation-host-metrics": "*", "@opentelemetry/instrumentation-runtime-node": "*", "@sap/cds-mtxs": "*", "@sap-cloud-sdk/connectivity": "^4", "@sap-cloud-sdk/http-client": "^4", "@sap-cloud-sdk/resilience": "^4" }, + "devDependencies": { + "@sap/cds-dk": "*" + }, "cds": { "requires": { "telemetry": { @@ -21,10 +24,6 @@ "config": { "ignoreIncomingRequestHook": "./lib/MyIgnoreIncomingRequestHook.js" } - }, - "instrumentation-runtime-node": { - "class": "RuntimeNodeInstrumentation", - "module": "@opentelemetry/instrumentation-runtime-node" } }, "_tracing": { From f623a7ff21723bdb4aa7cbeea865d9ff63838a6e Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 26 Jun 2026 16:26:00 +0200 Subject: [PATCH 06/13] chore: VCAP_SERVICES_FILE_PATH compatibility using greedy matchers (#407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Improve `VCAP_SERVICES` Service Binding Resolution with Greedy Matchers ### Chore 🔧 Updated service instance matching for SAP Cloud Logging and Dynatrace to use greedy (array-based) VCAP matchers, extending the default matching without breaking existing behavior. Error messages and documentation have been updated to guide users toward the correct configuration. ### Changes * `package.json`: Updated `vcap` matching for `telemetry-to-dynatrace` and `telemetry-to-cloud-logging` from a single object to an array of matchers. Dynatrace now matches by `label: "dynatrace"` or `tag: "dynatrace"`; Cloud Logging matches by `label: "cloud-logging"` or `tag: "Cloud Logging"`. * `lib/utils.js`: Improved inline comments to clearly document the legacy compatibility behavior for `getCredsForDTAsUPS()` and `getCredsForCLSAsUPS()`. Updated the warning message for Cloud Logging user-provided services to recommend only the `"Cloud Logging"` tag (dropped `"cloud-logging"` from the recommendation). * `lib/logging/index.js`, `lib/metrics/index.js`, `lib/tracing/index.js`: Improved error messages when credentials are not found — now explicitly instruct users to ensure the bound service instance uses the correct tag (`"Cloud Logging"` or `"dynatrace"`). * `README.md`: Clarified that user-provided service instances for SAP Cloud Logging must use the tag `"Cloud Logging"` (removed reference to the legacy `cloud-logging` tag). Added a tip with the `cf update-user-provided-service` command and a link to CAP's service binding documentation. - [ ] 🔄 Regenerate and Update Summary
PR Bot Information **Version:** `1.26.5` - Correlation ID: `81bbd7c7-2290-4812-89e9-d178ac318c25` - Event Trigger: `issue_comment.edited`
--------- Co-authored-by: sjvans <30337871+sjvans@users.noreply.github.com> --- README.md | 8 +++++++- lib/logging/index.js | 2 +- lib/metrics/index.js | 6 ++---- lib/tracing/index.js | 4 ++-- lib/utils.js | 15 +++++++++++---- package.json | 14 ++++++++------ 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 02c68cc2..6843b802 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,13 @@ In order to receive OpenTelemetry credentials in the binding to the SAP Cloud Lo } ``` -If you are binding your app to SAP Cloud Logging via a [user-provided service instance](https://docs.cloudfoundry.org/devguide/services/user-provided.html), make sure that it has either tag `cloud-logging` or `Cloud Logging`. +If you are binding your app to SAP Cloud Logging via a [user-provided service instance](https://docs.cloudfoundry.org/devguide/services/user-provided.html), make sure that it has the tag `Cloud Logging`. + +> Tip: To add the required tag to an existing user-provided service, you can use: +> ``` +> cf update-user-provided-service {service-name} -t "Cloud Logging" +> ``` +> For detailed information about binding resolution in CAP, consult [`cds.connect()` → Service Bindings](https://cap.cloud.sap/docs/node.js/cds-connect#service-bindings). ### `telemetry-to-jaeger` diff --git a/lib/logging/index.js b/lib/logging/index.js index 6654a1c3..08840201 100644 --- a/lib/logging/index.js +++ b/lib/logging/index.js @@ -40,7 +40,7 @@ function _getExporter() { if (kind.match(/to-cloud-logging$/)) { if (!credentials) credentials = getCredsForCLSAsUPS() - if (!credentials) throw new Error('No SAP Cloud Logging credentials found.') + if (!credentials) throw new Error('No SAP Cloud Logging credentials found. Make sure the bound service instance uses the tag "Cloud Logging".') augmentCLCreds(credentials) config.url ??= credentials.url config.credentials ??= credentials.credentials diff --git a/lib/metrics/index.js b/lib/metrics/index.js index 4eba5620..0dd12c0b 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -55,8 +55,7 @@ function _getExporter() { // Augment configuration depending on 'kind' of telemetry if (kind.match(/to-dynatrace$/)) { if (!credentials) credentials = getCredsForDTAsUPS() - if (!credentials) throw new Error('No Dynatrace credentials found.') - + if (!credentials) throw new Error('No Dynatrace credentials found. Make sure the bound service instance uses the tag "dynatrace".') config.url ??= `${credentials.apiurl}/v2/otlp/v1/metrics` config.headers ??= {} @@ -73,8 +72,7 @@ function _getExporter() { if (kind.match(/to-cloud-logging$/)) { if (!credentials) credentials = getCredsForCLSAsUPS() - if (!credentials) throw new Error('No SAP Cloud Logging credentials found.') - + if (!credentials) throw new Error('No SAP Cloud Logging credentials found. Make sure the bound service instance uses the tag "Cloud Logging".') augmentCLCreds(credentials) config.url ??= credentials.url diff --git a/lib/tracing/index.js b/lib/tracing/index.js index fcb77703..29105527 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -107,7 +107,7 @@ function _getExporter() { if (kind.match(/to-dynatrace$/)) { if (!credentials) credentials = getCredsForDTAsUPS() - if (!credentials) throw new Error('No Dynatrace credentials found') + if (!credentials) throw new Error('No Dynatrace credentials found. Make sure the bound service instance uses the tag "dynatrace".') config.url ??= `${credentials.apiurl}/v2/otlp/v1/traces` config.headers ??= {} // credentials.rest_apitoken?.token is deprecated and only supported for compatibility reasons @@ -120,7 +120,7 @@ function _getExporter() { if (kind.match(/to-cloud-logging$/)) { if (!credentials) credentials = getCredsForCLSAsUPS() - if (!credentials) throw new Error('No SAP Cloud Logging credentials found') + if (!credentials) throw new Error('No SAP Cloud Logging credentials found. Make sure the bound service instance uses the tag "Cloud Logging".') augmentCLCreds(credentials) config.url ??= credentials.url config.credentials ??= credentials.credentials diff --git a/lib/utils.js b/lib/utils.js index 33c099ca..f72c814e 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -107,7 +107,11 @@ function getCredsForDTAsUPS() { if (!process.env.VCAP_SERVICES) return const vcap = JSON.parse(process.env.VCAP_SERVICES) - // to support connection via user-provided services, APMs requirement is that the instance name contains "dynatrace" + // Legacy Compat: + // > APMs requirement is that the instance name contains "dynatrace" + // > In addition to matching predicate defined in package.json, also support + // > - name matching /dynatrace/ + // ... in case binding info is available from environment variable VCAP_SERVICES const dt = vcap['user-provided']?.find(b => b.name.match(/dynatrace/)) if (dt) return dt.credentials } @@ -117,15 +121,18 @@ function getCredsForCLSAsUPS() { const vcap = JSON.parse(process.env.VCAP_SERVICES) let ups - // to support connection via user-provided services, the instance must have either tag "cloud-logging" or "Cloud Logging" + // Legacy Compat: + // > In addition to matching predicate defined in package.json, also support + // > - tag: "cloud-logging" + // > - name matching /cloud-logging/ + // ... in case binding info is available from environment variable VCAP_SERVICES ups = vcap['user-provided']?.find(e => e.tags.includes('cloud-logging') || e.tags.includes('Cloud Logging')) if (ups) return ups.credentials - // legacy compat ups = vcap['user-provided']?.find(b => b.name.match(/cloud-logging/)) if (ups) { // prettier-ignore - LOG._warn && LOG.warn('User-provided service instances of SAP Cloud Logging should have either tag "cloud-logging" or "Cloud Logging"') + LOG._warn && LOG.warn('User-provided service instances of SAP Cloud Logging should have the tag "Cloud Logging"') return ups.credentials } } diff --git a/package.json b/package.json index 5b446cae..05b62db2 100644 --- a/package.json +++ b/package.json @@ -109,9 +109,10 @@ } }, "telemetry-to-dynatrace": { - "vcap": { - "label": "dynatrace" - }, + "vcap": [ + { "label": "dynatrace" }, + { "tag": "dynatrace" } + ], "tracing": { "exporter": { "module": "@opentelemetry/exporter-trace-otlp-proto", @@ -127,9 +128,10 @@ "token_name": "ingest_apitoken" }, "telemetry-to-cloud-logging": { - "vcap": { - "label": "cloud-logging" - }, + "vcap": [ + { "label": "cloud-logging" }, + { "tag": "Cloud Logging" } + ], "tracing": { "exporter": { "module": "@opentelemetry/exporter-trace-otlp-grpc", From 89c985eac9e0003c7816a3f30198e4b704b9ae10 Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:32:17 +0200 Subject: [PATCH 07/13] fix: workaround for cds.cli.command bug (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Fix: Workaround for `cds.cli.command` Bug with `build` Command ### Bug Fix 🐛 Added a workaround to prevent the telemetry plugin from loading when the `cds build` command is invoked. Due to a bug in `cds.cli.command`, the `build` command incorrectly resolves to an empty string (`''`), causing the plugin initialization check to pass unintentionally. ### Changes * `cds-plugin.js`: Added an early return guard that checks if `build` is present in `process.argv`. This ensures the plugin exits before proceeding with initialization when the `cds build` command is used, mirroring the existing workaround for `cds add`. - [ ] 🔄 Regenerate and Update Summary
PR Bot Information **Version:** `1.26.5` - Correlation ID: `4743ca7e-193a-4675-a529-8872b09ede9e` - Summary Prompt: [Default Prompt](https://github.tools.sap/intelligent-insights/i2-pull-request/blob/main/src/services/llm/prompts/summary_instructions_prompt.md) - LLM: `anthropic--claude-4.6-sonnet` - Output Template: [Default Template](https://github.tools.sap/intelligent-insights/i2-pull-request/blob/main/src/services/llm/prompts/summary_default_output_template.md) - Event Trigger: `pull_request.ready_for_review` - File Content Strategy: Full file content
--------- Co-authored-by: hyperspace-insights[bot] <209611008+hyperspace-insights[bot]@users.noreply.github.com> --- cds-plugin.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cds-plugin.js b/cds-plugin.js index 1704e142..a59ca32b 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -2,6 +2,10 @@ const cds = require('@sap/cds') if (!(cds.cli?.command in { '': 1, serve: 1, run: 1 })) return + // REVISIT: workaround for cds.cli.command bug + const j = process.argv.indexOf('build') + if (j > 1 && process.argv[j - 1].match(/cds(\.js)?$/)) return + // cds add XXX currently also has cli.command === '' const i = process.argv.indexOf('add') if (i > 1 && process.argv[i - 1].match(/cds(\.js)?$/)) return From 1583096f0d959c7ac5186c395c9cc9bede0b1c52 Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:31:49 +0200 Subject: [PATCH 08/13] chore: cds^10 (#443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Chore: Upgrade to `cds^10` — Migrate Test Config to `.cdsrc.json` and CDS Profiles ### Refactor ♻️ Migrates test configuration from inline `process.env` overrides to a dedicated `.cdsrc.json` file and CDS CLI profiles, aligning with the `cds^10` approach for environment/profile-based configuration. Also includes test timing optimizations and minor bug fixes. ### Changes * `.github/workflows/ci.yml`: Commented out the `cds@8` downgrade step; updated comment to reference `cds@9`. * `.github/workflows/release.yml`: Added a `REVISIT` comment to remove the `better-sqlite3` install workaround with `cds^10`. * `.gitignore`: Removed `test/bookshop/.cdsrc.json` from the ignore list so it can be tracked in version control. * `test/bookshop/.cdsrc.json`: New file defining CDS profiles (`logging`, `metrics`, `metrics-outbox`, `metrics-outbox-disabled`, `tracing-attributes`, `persistent-outbox`, `without-outbox`) consolidating telemetry, messaging, and metrics settings previously scattered across test files. * `test/bookshop/package.json`: Removed `_outbox` and profile-specific `[metrics-outbox]`/`[metrics-outbox-disabled]` blocks (now in `.cdsrc.json`); added `_scheduling: true`. * `test/logging.test.js`: Removed inline `process.env` overrides for telemetry/log format; switched to `cds.test(..., '--profile', 'logging')` with a minimal remaining env override for `cls_custom_fields`. * `test/metrics.test.js`: Removed `exportIntervalMillis` env override; switched to `cds.test(..., '--profile', 'metrics')`. * `test/metrics-outbox.test.js` / `test/metrics-outbox-multitenant.test.js`: Reduced metric collection wait times from `300ms` to `150ms`; fixed retry loop bug where both loops checked `E1` instead of `E1`/`E2` respectively. * `test/tracing-attributes.test.js`: Removed inline exporter env var; switched to `cds.test(..., '--profile', 'tracing-attributes')`. * `test/tracing-messaging-persistent-outbox.test.js`: Renamed `CASE` to `persistent-outbox`; simplified env setup; marked `describe` block as `skip` pending vitest migration. * `test/tracing-messaging-without-outbox.test.js`: Renamed `CASE` to `without-outbox`; simplified env setup using `outboxed: false`. * `test/tracing-messaging-with-in-memory-outbox.test.js`: Removed this test file entirely. * `test/tracing-messaging.js`: Switched from `cds.test().in(...)` to `cds.test(..., '--profile', CASE)`. * `test/tracing.test.js`: Switched from `cds.test().in(...)` to `cds.test(...)`. - [ ] 🔄 Regenerate and Update Summary
PR Bot Information **Version:** `1.26.11` - Summary Prompt: [Default Prompt](https://github.tools.sap/intelligent-insights/i2-pull-request/blob/main/src/services/llm/prompts/summary_instructions_prompt.md) - Correlation ID: `14c5ae4d-4c97-4eff-a004-330c9fdcf2fe` - Event Trigger: `pull_request.edited` - File Content Strategy: Full file content - Output Template: [Default Template](https://github.tools.sap/intelligent-insights/i2-pull-request/blob/main/src/services/llm/prompts/summary_default_output_template.md) - LLM: `anthropic--claude-4.6-sonnet`
--- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 1 + .gitignore | 1 - CHANGELOG.md | 3 + package.json | 6 +- test/bookshop/.cdsrc.json | 102 ++++++++++++++++++ test/bookshop/package.json | 40 +------ test/logging.test.js | 27 +---- test/metrics-outbox-multitenant.test.js | 4 +- test/metrics-outbox.test.js | 20 ++-- test/metrics.test.js | 3 +- test/tracing-attributes.test.js | 4 +- ...acing-messaging-persistent-outbox.test.js} | 16 ++- ...ng-messaging-with-in-memory-outbox.test.js | 18 ---- test/tracing-messaging-without-outbox.test.js | 13 +-- test/tracing-messaging.js | 2 +- test/tracing.test.js | 2 +- 17 files changed, 145 insertions(+), 119 deletions(-) create mode 100644 test/bookshop/.cdsrc.json rename test/{tracing-messaging-with-persistent-outbox.test.js => tracing-messaging-persistent-outbox.test.js} (58%) delete mode 100644 test/tracing-messaging-with-in-memory-outbox.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d44def50..534f3fe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm i -g @sap/cds-dk@${{ matrix.cds-version }} - run: npm i - - run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm i -f @sap/cds@8 @cap-js/sqlite@2 @sap/cds-mtxs@3; fi + #- run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm i -f @sap/cds@9 @cap-js/sqlite@2 @sap/cds-mtxs@3; fi - run: if [ ${{ matrix.cds-version }} -eq 9 ]; then npm explore better-sqlite3 -- npm run install; fi - run: cds v - run: npm run test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cba742ad..c554db70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,7 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org/ - name: run tests + # REVISIT: remove "npm explore better-sqlite3 -- npm run install" with cds^10 run: | npm i -g @sap/cds-dk npm i diff --git a/.gitignore b/.gitignore index dc23330c..9640f87f 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,5 @@ dist # test test/msg-box test/bookshop/gen -test/bookshop/.cdsrc.json .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb92460..b791e927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). - Support for OpenTelemetry SDK 2.0 - `@opentelemetry/instrumentation-undici` added to the list of default instrumentations +- Support for `@sap/cds^10` ### Changed @@ -21,6 +22,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Removed +- Support for `@sap/cds^8` + ## Version 1.6.0 - 2025-12-16 ### Added diff --git a/package.json b/package.json index 05b62db2..ed20d4a5 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ "@opentelemetry/semantic-conventions": "^1.41" }, "peerDependencies": { - "@sap/cds": "^8 || ^9" + "@sap/cds": "^9" }, "devDependencies": { "@cap-js/cds-test": "^0", - "@cap-js/sqlite": "^1 || ^2", + "@cap-js/sqlite": "^2", "@cap-js/telemetry": "file:.", "@grpc/grpc-js": "^1.9.14", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.219", @@ -43,7 +43,7 @@ "@opentelemetry/exporter-trace-otlp-proto": "^0.219", "@opentelemetry/instrumentation-host-metrics": "^0.2.0", "@opentelemetry/instrumentation-runtime-node": "^0.32.0", - "@sap/cds-mtxs": "^2 || ^3", + "@sap/cds-mtxs": "^3", "axios": "^1.6.7", "chai": "^4.4.1", "chai-as-promised": "^7.1.1", diff --git a/test/bookshop/.cdsrc.json b/test/bookshop/.cdsrc.json new file mode 100644 index 00000000..1dc257b9 --- /dev/null +++ b/test/bookshop/.cdsrc.json @@ -0,0 +1,102 @@ +{ + "[logging]": { + "requires": { + "telemetry": { + "tracing": { + "sampler": { + "ignoreIncomingPaths": ["/odata/v4/admin/Genres"] + } + }, + "logging": { + "exporter": { + "module": "@opentelemetry/sdk-logs", + "class": "ConsoleLogRecordExporter" + }, + "processor": { + "module": "./lib/MySimpleLogRecordProcessor.js", + "class": "MySimpleLogRecordProcessor" + } + } + } + }, + "log": { + "format": "json", + "cls_custom_fields": ["foo"] + } + }, + "[metrics]": { + "requires": { + "telemetry": { + "metrics": { + "config": { + "exportIntervalMillis": 100 + } + } + } + } + }, + "[metrics-outbox]": { + "requires": { + "queue": true, + "telemetry": { + "metrics": { + "config": { + "exportIntervalMillis": 100 + }, + "_db_pool": false, + "_queue": true, + "exporter": { + "module": "@opentelemetry/sdk-metrics", + "class": "ConsoleMetricExporter" + } + } + } + } + }, + "[metrics-outbox-disabled]": { + "requires": { + "queue": true, + "telemetry": { + "metrics": { + "config": { + "exportIntervalMillis": 250 + }, + "_db_pool": false, + "_queue": false, + "exporter": { + "module": "@opentelemetry/sdk-metrics", + "class": "ConsoleMetricExporter" + } + } + } + } + }, + "[tracing-attributes]": { + "requires": { + "telemetry": { + "tracing": { + "exporter": { + "module": "@opentelemetry/sdk-trace-node" + } + } + } + } + }, + "[persistent-outbox]": { + "requires": { + "messaging": { + "kind": "file-based-messaging", + "file": "../persistent-outbox" + } + } + }, + "[without-outbox]": { + "requires": { + "messaging": { + "kind": "file-based-messaging", + "file": "../without-outbox", + "outboxed": false + } + } + } +} diff --git a/test/bookshop/package.json b/test/bookshop/package.json index f5a04c2c..a1c014e4 100644 --- a/test/bookshop/package.json +++ b/test/bookshop/package.json @@ -44,12 +44,10 @@ "_kind": "file-based-messaging", "file": "../msg-box" }, - "_outbox": { - "kind": "persistent-outbox" - }, "queue": { "legacyLocking": false }, + "_scheduling": true, "ExternalServiceOne": { "kind": "odata", "model": "srv/external/ExternalServiceOne" @@ -79,42 +77,6 @@ } } }, - "[metrics-outbox]": { - "requires": { - "queue": true, - "telemetry": { - "metrics": { - "config": { - "exportIntervalMillis": 100 - }, - "_db_pool": false, - "_queue": true, - "exporter": { - "module": "@opentelemetry/sdk-metrics", - "class": "ConsoleMetricExporter" - } - } - } - } - }, - "[metrics-outbox-disabled]": { - "requires": { - "queue": true, - "telemetry": { - "metrics": { - "config": { - "exportIntervalMillis": 250 - }, - "_db_pool": false, - "_queue": false, - "exporter": { - "module": "@opentelemetry/sdk-metrics", - "class": "ConsoleMetricExporter" - } - } - } - } - }, "log": { "cls_custom_fields": [ "tenant_id" diff --git a/test/logging.test.js b/test/logging.test.js index 1a769e86..614b83dc 100644 --- a/test/logging.test.js +++ b/test/logging.test.js @@ -1,31 +1,10 @@ /* eslint-disable no-console */ -process.env.cds_log = JSON.stringify({ - format: 'json', - cls_custom_fields: ['foo'] -}) - -process.env.cds_requires_telemetry = JSON.stringify({ - tracing: { - sampler: { - ignoreIncomingPaths: ['/odata/v4/admin/Genres'] - } - }, - logging: { - exporter: { - module: '@opentelemetry/sdk-logs', - class: 'ConsoleLogRecordExporter' - }, - // experimental feature of the experimental feature!!! - processor: { - module: './lib/MySimpleLogRecordProcessor.js', - class: 'MySimpleLogRecordProcessor' - } - } -}) +// REVISIT: even with profile "logging", cls_custom_fields from package.json wins +process.env.cds_log = JSON.stringify({ cls_custom_fields: ['foo'] }) const cds = require('@sap/cds') -const { expect, GET } = cds.test().in(__dirname + '/bookshop') +const { expect, GET } = cds.test(__dirname + '/bookshop', '--profile', 'logging') describe('logging', () => { const admin = { auth: { username: 'alice' } } diff --git a/test/metrics-outbox-multitenant.test.js b/test/metrics-outbox-multitenant.test.js index d7be5ec8..5205d7c4 100644 --- a/test/metrics-outbox-multitenant.test.js +++ b/test/metrics-outbox-multitenant.test.js @@ -78,7 +78,7 @@ describe('queue metrics for multi tenant service', () => { GET('/odata/v4/proxy/proxyCallToExternalServiceOne', user[T2]) ]) - await wait(300) // Wait for metrics to be collected + await wait(150) // Wait for metrics to be collected expect(metricValue(T1, 'cold_entries')).to.eq(0) expect(metricValue(T1, 'incoming_messages')).to.eq(totalInc[T1]) @@ -138,7 +138,7 @@ describe('queue metrics for multi tenant service', () => { if (timeAfterFirstRetry - timeOfInitialCall < 1000) { await wait(1000 - (timeAfterFirstRetry - timeOfInitialCall)) } - await wait(300) // ... for metrics to be collected + await wait(150) // ... for metrics to be collected expect(metricValue(T1, 'cold_entries')).to.eq(0) expect(metricValue(T1, 'incoming_messages')).to.eq(totalInc[T1]) diff --git a/test/metrics-outbox.test.js b/test/metrics-outbox.test.js index c7bd7e57..911bac97 100644 --- a/test/metrics-outbox.test.js +++ b/test/metrics-outbox.test.js @@ -86,7 +86,7 @@ describe('queue metrics for single tenant service', () => { test('metrics are collected', async () => { await GET('/odata/v4/proxy/proxyCallToExternalServiceOne', admin) - await wait(300) // Wait for metrics to be collected + await wait(150) // Wait for metrics to be collected expect(metricValue('cold_entries', E1)).to.eq(0) expect(metricValue('remaining_entries', E1)).to.eq(0) @@ -99,7 +99,7 @@ describe('queue metrics for single tenant service', () => { await GET('/odata/v4/proxy/proxyCallToExternalServiceTwo', admin) - await wait(300) // Wait for metrics to be collected + await wait(150) // Wait for metrics to be collected expect(metricValue('cold_entries', E2)).to.eq(0) expect(metricValue('remaining_entries', E2)).to.eq(0) @@ -149,8 +149,9 @@ describe('queue metrics for single tenant service', () => { await GET('/odata/v4/proxy/proxyCallToExternalServiceOne', admin) await GET('/odata/v4/proxy/proxyCallToExternalServiceTwo', admin) - await wait(300) // ... for metrics to be collected + await wait(150) // ... for metrics to be collected expect(currentRetryCount[E1]).to.eq(1) + expect(currentRetryCount[E2]).to.eq(1) expect(metricValue('cold_entries', E1)).to.eq(0) expect(metricValue('remaining_entries', E1)).to.eq(1) @@ -171,8 +172,8 @@ describe('queue metrics for single tenant service', () => { expect(metricValue('max_storage_time_in_seconds', E2)).to.eq(0) // Wait for the first retry to be initiated - while (currentRetryCount[E1] < 2) await wait(100) - while (currentRetryCount[E1] < 2) await wait(100) + while (currentRetryCount[E1] < 2) await wait(10) + while (currentRetryCount[E2] < 2) await wait(10) await wait(150) // ... for the retry to be processed and metrics to be collected expect(currentRetryCount[E1]).to.eq(2) expect(currentRetryCount[E2]).to.eq(2) @@ -183,7 +184,7 @@ describe('queue metrics for single tenant service', () => { await wait(1000 - (timeAfterFirstRetry - timeOfInitialCall)) } - await wait(300) // ... for metrics to be collected again + await wait(150) // ... for metrics to be collected again expect(metricValue('cold_entries', E1)).to.eq(0) expect(metricValue('remaining_entries', E1)).to.eq(1) @@ -204,8 +205,9 @@ describe('queue metrics for single tenant service', () => { expect(metricValue('max_storage_time_in_seconds', E2)).to.be.gte(1) // Wait for the second retry to be initiated - while (currentRetryCount[E1] < 3) await wait(100) - await wait(300) // ... for the retry to be processed and metrics to be collected + while (currentRetryCount[E1] < 3) await wait(10) + while (currentRetryCount[E2] < 3) await wait(10) + await wait(150) // ... for the retry to be processed and metrics to be collected expect(currentRetryCount[E1]).to.eq(3) expect(currentRetryCount[E2]).to.eq(3) @@ -258,7 +260,7 @@ describe('queue metrics for single tenant service', () => { await GET('/odata/v4/proxy/proxyCallToExternalServiceOne', admin) await GET('/odata/v4/proxy/proxyCallToExternalServiceTwo', admin) - await wait(300) // ... for metrics to be collected + await wait(150) // ... for metrics to be collected expect(metricValue('cold_entries', E1)).to.eq(1) expect(metricValue('remaining_entries', E1)).to.eq(0) diff --git a/test/metrics.test.js b/test/metrics.test.js index 312af38e..78765d24 100644 --- a/test/metrics.test.js +++ b/test/metrics.test.js @@ -1,9 +1,8 @@ // process.env.HOST_METRICS_RETAIN_SYSTEM = 'true' //> with this the test would fail process.env.HOST_METRICS_LOG_SYSTEM = 'true' -process.env.cds_requires_telemetry_metrics_config = JSON.stringify({ exportIntervalMillis: 100 }) const cds = require('@sap/cds') -const { expect, GET } = cds.test().in(__dirname + '/bookshop') +const { expect, GET } = cds.test(__dirname + '/bookshop', '--profile', 'metrics') const log = cds.test.log() const wait = require('node:timers/promises').setTimeout diff --git a/test/tracing-attributes.test.js b/test/tracing-attributes.test.js index 6b90733d..a6474642 100644 --- a/test/tracing-attributes.test.js +++ b/test/tracing-attributes.test.js @@ -1,7 +1,5 @@ -process.env.cds_requires_telemetry_tracing_exporter_module = '@opentelemetry/sdk-trace-node' - const cds = require('@sap/cds') -const { expect, data } = cds.test().in(__dirname + '/bookshop') +const { expect, data } = cds.test(__dirname + '/bookshop', '--profile', 'tracing-attributes') const http = require('http') describe('tracing attributes', () => { diff --git a/test/tracing-messaging-with-persistent-outbox.test.js b/test/tracing-messaging-persistent-outbox.test.js similarity index 58% rename from test/tracing-messaging-with-persistent-outbox.test.js rename to test/tracing-messaging-persistent-outbox.test.js index 6744a4b1..1d959595 100644 --- a/test/tracing-messaging-with-persistent-outbox.test.js +++ b/test/tracing-messaging-persistent-outbox.test.js @@ -1,15 +1,12 @@ -const CASE = 'with_persistent-outbox' +const CASE = 'persistent-outbox' -const env = { +// REVISIT: even with profile "persistent-outbox", messaging kind and file from package.json wins +process.env.cds_requires_messaging = JSON.stringify({ kind: 'file-based-messaging', - outbox: true, file: `../${CASE}` -} -process.env.cds_requires_messaging = JSON.stringify(env) -process.env.cds_requires_outbox = JSON.stringify({ kind: 'persistent-outbox' }) - -process.env.cds_requires_telemetry_metrics = null +}) +// REVISIT: check json exports const CHECK = (log, expect) => { // 3: outbox -> consumers get new root context // REVISIT: for some reason, span "cds.spawn run task" has no parent when running in jest @@ -17,6 +14,7 @@ const CHECK = (log, expect) => { expect(log.output.match(/cds.spawn - schedule task/g).length).to.equal(1) } -describe(`tracing messaging - ${CASE}`, () => { +// REVISIT: re-enable with switch to vitest +describe.skip(`tracing messaging - ${CASE}`, () => { require('./tracing-messaging')(CASE, CHECK) }) diff --git a/test/tracing-messaging-with-in-memory-outbox.test.js b/test/tracing-messaging-with-in-memory-outbox.test.js deleted file mode 100644 index c6b6e804..00000000 --- a/test/tracing-messaging-with-in-memory-outbox.test.js +++ /dev/null @@ -1,18 +0,0 @@ -const CASE = 'with_in_memory_outbox' - -const env = { - kind: 'file-based-messaging', - outbox: { kind: 'in-memory-outbox' }, - file: `../${CASE}` -} -process.env.cds_requires_messaging = JSON.stringify(env) - -const CHECK = (log, expect) => { - // 2: no outbox -> consumer gets new root context - // REVISIT: for some reason, the emit done in the on succeeded callback gets a new root context when running in jest - expect(log.output.match(/\[telemetry\] - elapsed times:/g).length).to.equal(3) //> actually 2 -} - -describe(`tracing messaging - ${CASE}`, () => { - require('./tracing-messaging')(CASE, CHECK) -}) diff --git a/test/tracing-messaging-without-outbox.test.js b/test/tracing-messaging-without-outbox.test.js index cc941b6c..f93664c1 100644 --- a/test/tracing-messaging-without-outbox.test.js +++ b/test/tracing-messaging-without-outbox.test.js @@ -1,12 +1,13 @@ -const CASE = 'without_outbox' +const CASE = 'without-outbox' -const env = { +// REVISIT: even with profile "without-outbox", messaging kind and file from package.json wins +process.env.cds_requires_messaging = JSON.stringify({ kind: 'file-based-messaging', - outbox: false, - file: `../${CASE}` -} -process.env.cds_requires_messaging = JSON.stringify(env) + file: `../${CASE}`, + outboxed: false +}) +// REVISIT: check json exports const CHECK = (log, expect) => { // 2: no outbox -> consumer gets new root context expect(log.output.match(/\[telemetry\] - elapsed times:/g).length).to.equal(2) diff --git a/test/tracing-messaging.js b/test/tracing-messaging.js index 9bcc3dc0..35508745 100644 --- a/test/tracing-messaging.js +++ b/test/tracing-messaging.js @@ -1,6 +1,6 @@ module.exports = (CASE, CHECK) => { const cds = require('@sap/cds') - const { expect, POST } = cds.test().in(__dirname + '/bookshop') + const { expect, POST } = cds.test(__dirname + '/bookshop', '--profile', CASE) const log = cds.test.log() const wait = require('node:timers/promises').setTimeout diff --git a/test/tracing.test.js b/test/tracing.test.js index c6dba154..f3d0ce05 100644 --- a/test/tracing.test.js +++ b/test/tracing.test.js @@ -4,7 +4,7 @@ process.env.cds_requires_telemetry_tracing_sampler = JSON.stringify({ }) const cds = require('@sap/cds') -const { expect, GET, POST } = cds.test().in(__dirname + '/bookshop') +const { expect, GET, POST } = cds.test(__dirname + '/bookshop') const log = cds.test.log() const wait = require('node:timers/promises').setTimeout From 96eae90ae39e52c2e36f4a4ce47b213398792817 Mon Sep 17 00:00:00 2001 From: D050513 Date: Wed, 1 Jul 2026 09:54:40 +0200 Subject: [PATCH 09/13] fix: improve span name quality for DB and Cloud SDK spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB inner spans now show SQL verb + table name instead of raw SQL text (e.g. '@cap-js/sqlite - prepare SELECT sap.capire.bookshop.Books'). CDS operation names are mapped to SQL verbs (READ→SELECT, CREATE→INSERT). Raw SQL remains in the db.query.text attribute per OTel convention. Cloud SDK spans now use 'METHOD /path' (query string stripped) by default, or 'METHOD' only when adjust_root_name is false — consistent with how the HTTP root span name is controlled by the same flag. Destination name removed from the span name; still available as sap.btp.destination attribute. --- lib/tracing/cloud_sdk.js | 20 +++++--- lib/tracing/trace.js | 5 +- test/tracing-span-names.test.js | 91 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 test/tracing-span-names.test.js diff --git a/lib/tracing/cloud_sdk.js b/lib/tracing/cloud_sdk.js index 77a2c9b7..df11d2d9 100644 --- a/lib/tracing/cloud_sdk.js +++ b/lib/tracing/cloud_sdk.js @@ -1,8 +1,20 @@ const { SpanKind } = require('@opentelemetry/api') +const cds = require('@sap/cds') const trace = require('./trace') const wrap = require('./wrap') +const { adjust_root_name } = cds.env.requires.telemetry.tracing +const ADJUST_ROOT_NAME = adjust_root_name && adjust_root_name !== 'false' + +function _cloudSdkSpanName(destination, requestConfig) { + const method = requestConfig?.method || 'GET' + if (!ADJUST_ROOT_NAME) return method + const url = destination.url || requestConfig?.url || '' + const path = url.replace(/^https?:\/\/[^/]+/, '').split('?')[0] + return `${method}${path ? ' ' + path : ''}` +} + // REVISIT: unverified! module.exports = () => { try { @@ -16,9 +28,7 @@ module.exports = () => { cloudSDK.executeHttpRequest = wrap(_execute, { wrapper: function executeHttpRequest(destination, requestConfig) { return trace( - `${destination?.name ? destination?.name + ' ' : ''}${requestConfig?.method || 'GET'} ${ - destination.url || requestConfig?.url - }`, + _cloudSdkSpanName(destination, requestConfig), _execute, this, arguments, @@ -29,9 +39,7 @@ module.exports = () => { cloudSDK.executeHttpRequestWithOrigin = wrap(_executeWithOrigin, { wrapper: function executeHttpRequestWithOrigin(destination, requestConfig) { return trace( - `${destination?.name ? destination?.name + ' ' : ''}${requestConfig?.method || 'GET'} ${ - destination.url || requestConfig?.url - }`, + _cloudSdkSpanName(destination, requestConfig), _executeWithOrigin, this, arguments, diff --git a/lib/tracing/trace.js b/lib/tracing/trace.js index f7914446..8112ed8a 100644 --- a/lib/tracing/trace.js +++ b/lib/tracing/trace.js @@ -310,7 +310,10 @@ function trace(req, fn, that, args, opts = {}) { // REVISIT: with _hana_prom = true, the sql is not yet in the span name if (name.match(/^@cap-js\/\w+ - \w+$/) && options.attributes[ATTR_DB_QUERY_TEXT]) { - name += ' ' + options.attributes[ATTR_DB_QUERY_TEXT] + const SQL_VERB = { READ: 'SELECT', CREATE: 'INSERT' } + const operation = SQL_VERB[options.attributes[ATTR_DB_OPERATION_NAME]] ?? options.attributes[ATTR_DB_OPERATION_NAME] + const table = options.attributes[ATTR_DB_SQL_TABLE] + if (operation || table) name += ` ${[operation, table].filter(Boolean).join(' ')}` } if (name.length > 80 && _truncate_span_name !== false) name = name.substring(0, 79) + '…' diff --git a/test/tracing-span-names.test.js b/test/tracing-span-names.test.js new file mode 100644 index 00000000..35c6c10c --- /dev/null +++ b/test/tracing-span-names.test.js @@ -0,0 +1,91 @@ +const cds = require('@sap/cds') +const { expect, data } = cds.test(__dirname + '/bookshop', '--profile', 'tracing-attributes') +const { SELECT, INSERT } = cds.ql +const http = require('http') + +describe('span names', () => { + beforeEach(data.reset) + + const log = jest.spyOn(console, 'dir') + beforeEach(log.mockClear) + + const getSpans = () => log.mock.calls.map(c => c[0]).filter(Boolean) + + // Spans from our tracer only (excludes HTTP instrumentation spans) + const getCapSpans = () => getSpans().filter(s => s.instrumentationScope?.name === '@cap-js/telemetry') + const capSpanNames = () => getCapSpans().map(s => s.name) + + describe('db', () => { + test('SELECT: inner DB span name uses operation+table, not SQL', async () => { + await SELECT.from('sap.capire.bookshop.Books').where('title !=', 'DUMMY') + const names = capSpanNames() + // outer CAP span: event+target (unchanged) + expect(names).to.include('db - READ sap.capire.bookshop.Books') + // inner SQLite prepare span: SQL verb + table appended, not raw SQL + expect(names.some(n => /^@cap-js\/\w+ - prepare SELECT sap\.capire\.bookshop\.Books$/.test(n))).to.be.true + // no span name should carry raw SQL + for (const name of names) { + expect(name, `span "${name}" contains SQL`).not.to.match(/SELECT\s|json_insert|INSERT\s+INTO|UPDATE\s+\w/) + } + }) + + test('SELECT: inner DB span has db.query.text attribute with SQL', async () => { + await SELECT.from('sap.capire.bookshop.Books').where('title !=', 'DUMMY') + const spans = getCapSpans() + const prepareSpan = spans.find(s => /^@cap-js\/\w+ - prepare /.test(s.name)) + expect(prepareSpan).to.exist + // SQL lives in the attribute, not the name + expect(prepareSpan.attributes['db.query.text']).to.match(/SELECT/) + expect(prepareSpan.attributes['db.operation.name']).to.equal('READ') + expect(prepareSpan.attributes['db.sql.table']).to.equal('sap.capire.bookshop.Books') + }) + + test('raw SQL via db.run: span name preserves the SQL string', async () => { + const db = await cds.connect.to('db') + await db.run('SELECT ID, title FROM sap_capire_bookshop_Books WHERE ID = 201') + const names = capSpanNames() + // _getSpanName: event === undefined path -> "db - " + expect(names.some(n => n.startsWith('db - SELECT'))).to.be.true + }) + + test('INSERT: inner DB span name uses operation+table, not SQL', async () => { + await INSERT.into('sap.capire.bookshop.Books').entries([{ ID: 999 }]) + const names = capSpanNames() + expect(names).to.include('db - CREATE sap.capire.bookshop.Books') + for (const name of names) { + expect(name, `span "${name}" contains SQL`).not.to.match(/INSERT\s+INTO|json_insert/) + } + }) + }) + + describe('cloud sdk', () => { + 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(() => new Promise(resolve => server.close(resolve))) + + test('Cloud SDK span name uses destination+method, not URL', async () => { + // Cloud SDK resilience module resolution issues in cds < 9 + if (Number(cds.version.split('.')[0]) < 9) return + + cds.env.requires.TestRemote = { kind: 'odata', credentials: { url: `http://localhost:${port}` } } + const remote = await cds.connect.to('TestRemote') + await remote.send({ method: 'GET', path: '/test' }) + + // no CAP-level span name should contain a URL + for (const name of capSpanNames()) { + expect(name, `span "${name}" contains URL`).not.to.match(/https?:\/\//) + } + }) + }) +}) From 81596992e4865ee3833f7009705939af04f9387f Mon Sep 17 00:00:00 2001 From: D050513 Date: Wed, 1 Jul 2026 10:01:29 +0200 Subject: [PATCH 10/13] branches: [main] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48ab15e4..24cf34d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: push: branches: [main] pull_request: - branches: [main, 2] + branches: [main] env: NPM_CONFIG_IGNORE_SCRIPTS: true From 166ee0f7685d2e3ad3f948f8f6aa55022d510320 Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:02:34 +0200 Subject: [PATCH 11/13] Update test/bookshop/package.json --- test/bookshop/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/test/bookshop/package.json b/test/bookshop/package.json index a1c014e4..bf574fe9 100644 --- a/test/bookshop/package.json +++ b/test/bookshop/package.json @@ -47,7 +47,6 @@ "queue": { "legacyLocking": false }, - "_scheduling": true, "ExternalServiceOne": { "kind": "odata", "model": "srv/external/ExternalServiceOne" From f74ccacb5f8bb8413d00e779dc9e3bafaa04eed1 Mon Sep 17 00:00:00 2001 From: D050513 Date: Wed, 1 Jul 2026 10:20:34 +0200 Subject: [PATCH 12/13] fix: also strip raw SQL from prepare/exec/stmt.* span names The augmentation block at trace.js was designed for the HANA-promisified path where the DB span name arrives as bare "@cap-js/hana - " (no SQL). For sqlite/pg/HANA-non-promisified, cds.js interpolates the SQL at emission time ("@cap-js/sqlite - prepare INSERT INTO ..."), so the strict "^@cap-js/\w+ - \w+$" regex never matched and raw SQL leaked into names. Widen the match to also accept a trailing SQL segment (and cover stmt.* function names with dots, which the old \w+ never matched). The augmented name uses the impl+verb prefix from the regex capture and appends " " from the attributes we already have. SQL remains in db.query.text per the OTel semantic convention. Also tighten the anti-SQL test assertion so it rejects raw SQL patterns (column lists, function calls, INSERT INTO, etc.) without colliding with the desired "prepare SELECT
" shape. Fix the load-order bug in the test: destructuring cds.ql at module top level captures references BEFORE cds.test() wires up the profile-specific tracer. Use the globals cds.test() injects instead. --- lib/tracing/trace.js | 15 +++++++++++---- test/tracing-span-names.test.js | 8 +++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/tracing/trace.js b/lib/tracing/trace.js index 8112ed8a..7778bb99 100644 --- a/lib/tracing/trace.js +++ b/lib/tracing/trace.js @@ -308,12 +308,19 @@ function trace(req, fn, that, args, opts = {}) { LOG._warn && LOG.warn('Failed to determine attributes:', err) } - // REVISIT: with _hana_prom = true, the sql is not yet in the span name - if (name.match(/^@cap-js\/\w+ - \w+$/) && options.attributes[ATTR_DB_QUERY_TEXT]) { + // Normalize DB span names built in cds.js (prepare/exec/stmt.*) so raw SQL + // doesn't leak into the name; the SQL is preserved in db.query.text. + // Matches "@cap-js/ - " optionally followed by " ", + // where is prepare | exec | stmt.. Covers both the sqlite/pg case + // (SQL is already baked in) and the HANA-promisified case (SQL not yet appended). + const dbNameMatch = name.match(/^(@cap-js\/\w+ - (?:prepare|exec|stmt\.\w+))(?:\s.*)?$/) + if (dbNameMatch && (options.attributes[ATTR_DB_OPERATION_NAME] || options.attributes[ATTR_DB_SQL_TABLE])) { const SQL_VERB = { READ: 'SELECT', CREATE: 'INSERT' } - const operation = SQL_VERB[options.attributes[ATTR_DB_OPERATION_NAME]] ?? options.attributes[ATTR_DB_OPERATION_NAME] + const op = options.attributes[ATTR_DB_OPERATION_NAME] + const verb = SQL_VERB[op] ?? op const table = options.attributes[ATTR_DB_SQL_TABLE] - if (operation || table) name += ` ${[operation, table].filter(Boolean).join(' ')}` + const tail = [verb, table].filter(Boolean).join(' ') + if (tail) name = `${dbNameMatch[1]} ${tail}` } if (name.length > 80 && _truncate_span_name !== false) name = name.substring(0, 79) + '…' diff --git a/test/tracing-span-names.test.js b/test/tracing-span-names.test.js index 35c6c10c..8cd1d7a8 100644 --- a/test/tracing-span-names.test.js +++ b/test/tracing-span-names.test.js @@ -1,6 +1,5 @@ const cds = require('@sap/cds') const { expect, data } = cds.test(__dirname + '/bookshop', '--profile', 'tracing-attributes') -const { SELECT, INSERT } = cds.ql const http = require('http') describe('span names', () => { @@ -23,9 +22,12 @@ describe('span names', () => { expect(names).to.include('db - READ sap.capire.bookshop.Books') // inner SQLite prepare span: SQL verb + table appended, not raw SQL expect(names.some(n => /^@cap-js\/\w+ - prepare SELECT sap\.capire\.bookshop\.Books$/.test(n))).to.be.true - // no span name should carry raw SQL + // no span name should carry raw SQL (function calls, column lists, etc.) + // but a bare "SELECT
" is the intended shape for (const name of names) { - expect(name, `span "${name}" contains SQL`).not.to.match(/SELECT\s|json_insert|INSERT\s+INTO|UPDATE\s+\w/) + expect(name, `span "${name}" contains raw SQL`).not.to.match( + /SELECT\s+\S+,|SELECT\s+\w*\(|json_insert|INSERT\s+INTO|UPDATE\s+\S+\s+SET|DELETE\s+FROM/ + ) } }) From 41e06cf2fc0f1366d452ed3694dffa556e6340fe Mon Sep 17 00:00:00 2001 From: sjvans Date: Fri, 3 Jul 2026 12:10:48 +0200 Subject: [PATCH 13/13] changelog --- CHANGELOG.md | 1 + lib/tracing/cloud_sdk.js | 6 +++--- lib/tracing/trace.js | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce43e09..72acf229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Fixed +- Readability of span names for DB and Cloud SDK traces - Peer dependency allows `@sap/cds^10` ## Version 2.0.0 - 2026-06-29 diff --git a/lib/tracing/cloud_sdk.js b/lib/tracing/cloud_sdk.js index df11d2d9..792472a4 100644 --- a/lib/tracing/cloud_sdk.js +++ b/lib/tracing/cloud_sdk.js @@ -4,12 +4,12 @@ const cds = require('@sap/cds') const trace = require('./trace') const wrap = require('./wrap') -const { adjust_root_name } = cds.env.requires.telemetry.tracing -const ADJUST_ROOT_NAME = adjust_root_name && adjust_root_name !== 'false' +const { _append_url_path } = cds.env.requires.telemetry.tracing +const APPEND_URL_PATH = _append_url_path && _append_url_path !== 'false' function _cloudSdkSpanName(destination, requestConfig) { const method = requestConfig?.method || 'GET' - if (!ADJUST_ROOT_NAME) return method + if (!APPEND_URL_PATH) return method const url = destination.url || requestConfig?.url || '' const path = url.replace(/^https?:\/\/[^/]+/, '').split('?')[0] return `${method}${path ? ' ' + path : ''}` diff --git a/lib/tracing/trace.js b/lib/tracing/trace.js index 7778bb99..dc9d70db 100644 --- a/lib/tracing/trace.js +++ b/lib/tracing/trace.js @@ -41,9 +41,9 @@ const MASK_HEADERS = (cds.env.log.mask_headers || ['/authorization/i', '/cookie/ const CRUD = { CREATE: 1, READ: 1, UPDATE: 1, DELETE: 1 } -const { hrtime, adjust_root_name, _truncate_span_name } = cds.env.requires.telemetry.tracing +const { hrtime, _append_url_path, _truncate_span_name } = cds.env.requires.telemetry.tracing const HRTIME = hrtime && hrtime !== 'false' -const ADJUST_ROOT_NAME = adjust_root_name && adjust_root_name !== 'false' +const APPEND_URL_PATH = _append_url_path && _append_url_path !== 'false' const $hrnow = Symbol('@cap-js/telemetry:hrnow') const $adjusted = Symbol('@cap-js/telemetry:adjusted') @@ -283,7 +283,7 @@ function trace(req, fn, that, args, opts = {}) { _setAttributes(parent, _getRequestAttributes()) const ctx = cds.context if (ctx?.http?.req?.[$hrnow]) parent.startTime = ctx.http.req[$hrnow] - if (ADJUST_ROOT_NAME && parent.attributes[ATTR_URL_PATH]) parent.name += ' ' + parent.attributes[ATTR_URL_PATH] + if (APPEND_URL_PATH && parent.attributes[ATTR_URL_PATH]) parent.name += ' ' + parent.attributes[ATTR_URL_PATH] } let name = typeof req === 'string' ? req : _getSpanName(req, fn, that)