diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f4cd0c9..24cf34d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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@v7.0.0 - 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@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 5256d14b..e05f01e6 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 9b0ce94b..9640f87f 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,5 @@ dist # test test/msg-box test/bookshop/gen -test/bookshop/.cdsrc.json + +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 828369f0..db0d79dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,26 @@ 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 - 2026-06-29 ### Added +- Support for OpenTelemetry SDK 2.0 +- `@opentelemetry/instrumentation-undici` added to the list of default instrumentations +- Support for `@sap/cds^10` + ### Changed +- `@opentelemetry/instrumentation-host-metrics` replaces deprecated `@opentelemetry/host-metrics` + ### Fixed - Prefer `VCAP_APPLICATION.name` over `name` in `package.json` for app name resolution ### Removed +- Support for `@sap/cds^8` + ## Version 1.6.0 - 2025-12-16 ### Added diff --git a/README.md b/README.md index 0da2ebf1..6843b802 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`. @@ -289,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` @@ -395,6 +393,10 @@ Default: "http": { "module": "@opentelemetry/instrumentation-http", "class": "HttpInstrumentation" + }, + "undici": { + "module": "@opentelemetry/instrumentation-undici", + "class": "UndiciInstrumentation" } } ``` diff --git a/cds-plugin.js b/cds-plugin.js index a3b091f2..a59ca32b 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -2,47 +2,15 @@ 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 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..08840201 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' } } @@ -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 @@ -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..0dd12c0b 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,61 @@ 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.') + 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 ??= {} - // 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.') + 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 } - // 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 +88,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..29105527 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' } } @@ -106,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 @@ -119,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 @@ -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..f72c814e 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) @@ -106,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 } @@ -116,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 03583029..ed20d4a5 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", @@ -19,30 +19,31 @@ }, "dependencies": { "@opentelemetry/api": "^1.9", - "@opentelemetry/core": "^1.30", - "@opentelemetry/instrumentation": "^0.57", - "@opentelemetry/instrumentation-http": "^0.57", - "@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/core": "^2.8", + "@opentelemetry/instrumentation": "^0.219", + "@opentelemetry/instrumentation-http": "^0.219", + "@opentelemetry/instrumentation-undici": "^0.29", + "@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" + "@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.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", - "@sap/cds-mtxs": "^2 || ^3", + "@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": "^3", "axios": "^1.6.7", "chai": "^4.4.1", "chai-as-promised": "^7.1.1", @@ -61,6 +62,10 @@ "http": { "module": "@opentelemetry/instrumentation-http", "class": "HttpInstrumentation" + }, + "undici": { + "module": "@opentelemetry/instrumentation-undici", + "class": "UndiciInstrumentation" } }, "tracing": { @@ -104,9 +109,10 @@ } }, "telemetry-to-dynatrace": { - "vcap": { - "label": "dynatrace" - }, + "vcap": [ + { "label": "dynatrace" }, + { "tag": "dynatrace" } + ], "tracing": { "exporter": { "module": "@opentelemetry/exporter-trace-otlp-proto", @@ -122,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", 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 92c5c2cc..bf574fe9 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": { @@ -45,9 +44,6 @@ "_kind": "file-based-messaging", "file": "../msg-box" }, - "_outbox": { - "kind": "persistent-outbox" - }, "queue": { "legacyLocking": false }, @@ -80,42 +76,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..d0b4b747 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) @@ -144,13 +144,14 @@ describe('queue metrics for single tenant service', () => { }) test('storage time increases before message can be delivered', async () => { - const timeOfInitialCall = Date.now() - await GET('/odata/v4/proxy/proxyCallToExternalServiceOne', admin) await GET('/odata/v4/proxy/proxyCallToExternalServiceTwo', admin) - await wait(300) // ... for metrics to be collected + const timeOfInitialCall = Date.now() + + 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 24068e2c..a6474642 100644 --- a/test/tracing-attributes.test.js +++ b/test/tracing-attributes.test.js @@ -1,7 +1,6 @@ -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', () => { beforeEach(data.reset) @@ -9,6 +8,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 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