From 6588e3d51f57e8b7c4f221277fd3f769052d4807 Mon Sep 17 00:00:00 2001 From: D050513 Date: Thu, 21 May 2026 01:07:51 +0200 Subject: [PATCH 1/2] setup with calm --- lib/index.js | 31 +++---- lib/logging/index.js | 52 ++++++++--- lib/metrics/index.js | 109 ++++++++++++++---------- lib/metrics/queue.js | 1 - lib/tracing/index.js | 60 ++++++++----- lib/utils.js | 3 +- test/metrics-outbox-multitenant.test.js | 4 +- test/metrics-outbox.test.js | 6 +- 8 files changed, 160 insertions(+), 106 deletions(-) diff --git a/lib/index.js b/lib/index.js index 20ee2c22..1cbca4ba 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,13 +4,12 @@ const LOG = cds.log('telemetry') const path = require('path') const { diag } = require('@opentelemetry/api') -const { getStringFromEnv, diagLogLevelFromString } = require('@opentelemetry/core') 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 @@ -59,30 +58,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, @@ -90,3 +78,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 c7ce4f46..0dc48243 100644 --- a/lib/logging/index.js +++ b/lib/logging/index.js @@ -20,13 +20,15 @@ function _getExporter() { // for kind telemetry-to-otlp based on env vars if (loggingExporter === 'env') { - let protocol = getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_PROTOCOL') ?? getStringFromEnv('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 = (getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '') + const endpoint = + getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '' if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= (getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL')) + protocol ??= getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') loggingExporter = { module: _protocol2module[protocol], class: 'OTLPLogExporter' } } @@ -73,19 +75,11 @@ module.exports = resource => { const { logs, SeverityNumber } = require('@opentelemetry/api-logs') const { LoggerProvider, BatchLogRecordProcessor, SimpleLogRecordProcessor } = require('@opentelemetry/sdk-logs') - - const exporter = _getExporter() - const logProcessor = _getCustomProcessor(exporter) || - (process.env.NODE_ENV === 'production' - ? new BatchLogRecordProcessor(exporter) - : new SimpleLogRecordProcessor(exporter)) - - // TODO: CALM may have initialized a global provider already - - const loggerProvider = new LoggerProvider({ resource, processors: [logProcessor]}) - logs.setGlobalLoggerProvider(loggerProvider) + // 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' } @@ -132,5 +126,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/index.js b/lib/metrics/index.js index c0bfdca6..1a1fced5 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -14,7 +14,7 @@ const { const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') const _protocol2module = { - 'grpc': '@opentelemetry/exporter-metrics-otlp-grpc', + grpc: '@opentelemetry/exporter-metrics-otlp-grpc', 'http/protobuf': '@opentelemetry/exporter-metrics-otlp-proto', 'http/json': '@opentelemetry/exporter-metrics-otlp-http' } @@ -26,26 +26,29 @@ function _getExporter() { credentials } = cds.env.requires.telemetry - if (metricsExporter === 'env') { // ... process env to determine exporter module to use - let protocol = getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') - + if (metricsExporter === 'env') { + // ... process env to determine exporter module to use + let protocol = + getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') + if (!protocol) { // > 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') ?? '') + const endpoint = + getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '' if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= (getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL')) + protocol ??= + getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') metricsExporter = { module: _protocol2module[protocol], class: 'OTLPMetricExporter' } } // Import the configured exporter module > use _require for better error message - const metricsExporterModule = metricsExporter.module === '@cap-js/telemetry' - ? require('../exporter') - : _require(metricsExporter.module) + 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 @@ -53,26 +56,27 @@ function _getExporter() { 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 ??= {} - + // 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 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`) - + 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 } @@ -86,45 +90,58 @@ module.exports = resource => { if (!cds.env.requires.telemetry.metrics?.exporter) return /* - * general setup + * add individual metrics + */ + require('./db-pool')() + require('./queue')() + require('./host')() + + /* + * create reader */ const metricsConfig = cds.env.requires.telemetry.metrics.config - let exporter = _getExporter() - - if (typeof exporter.export === 'function') { + 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 wrappeed thus, to set an export interval - exporter = new PeriodicExportingMetricReader({ ...metricsConfig, exporter }) + reader = new PeriodicExportingMetricReader({ ...metricsConfig, exporter: reader }) } - const dtmetadata = getDynatraceMetadata(); - resource = resourceFromAttributes({}).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({ - meterName: "@cap-js/telemetry:host-metrics", - instrumentName: "system.*", - aggregation: { - type: AggregationType.DROP, - }, - }); + /* + * either add reader as delegate in CALM... + */ + 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 } - - // TODO: CALM may have initialized a global provider already - - const meterProvider = new MeterProvider({ resource, readers: [exporter], views }); - metrics.setGlobalMeterProvider(meterProvider); + } /* - * add individual metrics + * ... or initialize and return provider */ - require('./db-pool')() - require('./queue')() - require('./host')() - + const dtmetadata = getDynatraceMetadata() + resource = resourceFromAttributes({}).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({ + meterName: '@cap-js/telemetry:host-metrics', + instrumentName: 'system.*', + aggregation: { + type: AggregationType.DROP + } + }) + } + const meterProvider = new MeterProvider({ resource, readers: [reader], views }) + metrics.setGlobalMeterProvider(meterProvider) return meterProvider } diff --git a/lib/metrics/queue.js b/lib/metrics/queue.js index 8d06693b..362366da 100644 --- a/lib/metrics/queue.js +++ b/lib/metrics/queue.js @@ -180,7 +180,6 @@ module.exports = () => { const queuedServiceName = req.data.target if (!registeredServics.has(queuedServiceName)) { - const targetedService = cds.services[queuedServiceName] if (!targetedService) { LOG.debug('Skipping registration of queue metrics collection for unknown service:', queuedServiceName) diff --git a/lib/tracing/index.js b/lib/tracing/index.js index 7e97b001..fcb77703 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -85,13 +85,16 @@ function _getExporter() { // for kind telemetry-to-otlp based on env vars if (tracingExporter === 'env') { - let protocol = getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL') ?? getStringFromEnv('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 = (getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '') + const endpoint = + getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '' if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= (getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL')) + protocol ??= + getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') tracingExporter = { module: _protocol2module[protocol], class: 'OTLPTraceExporter' } } @@ -132,8 +135,23 @@ 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 + */ + require('./cds')() + require('./cloud_sdk')() + + /* + * create processor */ let processor const via_one_agent = @@ -152,26 +170,26 @@ module.exports = resource => { : new SimpleSpanProcessor(exporter, processorConfig) } - // TODO: CALM may have initialized a global provider already - - resource = resourceFromAttributes({}).merge(resource).merge(getDynatraceMetadata()) - const tracerProvider = new NodeTracerProvider({ resource, spanProcessors: [processor], sampler: _getSampler() }) - tracerProvider.register({ propagator: _getPropagator() }) - - // 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/utils.js b/lib/utils.js index bb135629..a91b1b1f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -50,7 +50,8 @@ function getResource() { attributes[ATTR_SERVICE_VERSION] = getStringFromEnv('OTEL_SERVICE_VERSION') || version // Service (Experimental) - if (getStringFromEnv('OTEL_SERVICE_NAMESPACE')) attributes[ATTR_SERVICE_NAMESPACE] = getStringFromEnv('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) { diff --git a/test/metrics-outbox-multitenant.test.js b/test/metrics-outbox-multitenant.test.js index e7a80085..38be07c2 100644 --- a/test/metrics-outbox-multitenant.test.js +++ b/test/metrics-outbox-multitenant.test.js @@ -137,7 +137,7 @@ describe('queue metrics for multi tenant service', () => { // Wait for the first retry to be processed while (currentRetryCount[T1] < 2) await wait(10) while (currentRetryCount[T2] < 2) await wait(10) - + // Wait until at least 1 second has passed since the initial call const timeAfterFirstRetry = Date.now() if (timeAfterFirstRetry - timeOfInitialCall < 1000) { @@ -192,7 +192,7 @@ describe('queue metrics for multi tenant service', () => { beforeAll(async () => { unboxedService = await cds.connect.to('ExternalService') - unboxedService.before('call', req => { + unboxedService.before('call', req => { didProcess[cds.context.tenant] = true return req.reject({ status: 418, unrecoverable: true }) }) diff --git a/test/metrics-outbox.test.js b/test/metrics-outbox.test.js index c4045249..dab0f256 100644 --- a/test/metrics-outbox.test.js +++ b/test/metrics-outbox.test.js @@ -19,7 +19,7 @@ const cds = require('@sap/cds') const { setTimeout: wait } = require('node:timers/promises') const { expect, GET } = cds.test(__dirname + '/bookshop', '--with-mocks') -const debugLog = cds.log('telemetry').debug = jest.fn(() => {}) +const debugLog = (cds.log('telemetry').debug = jest.fn(() => {})) function metricValue(metric) { const mostRecentMetricLog = consoleDirLogs.findLast( @@ -63,7 +63,6 @@ describe('queue metrics for single tenant service', () => { }) describe('given the target service succeeds immediately', () => { - test('metrics are collected', async () => { if (cds.version.split('.')[0] < 9) return @@ -188,8 +187,7 @@ describe('queue metrics for single tenant service', () => { describe('given someone tries to interact with the persistent outox table directly', () => { describe('app should not crash', () => { - - test('when a message targeting an unknown service is added to the persistent outbox table manually', async () => { + test('when a message targeting an unknown service is added to the persistent outbox table manually', async () => { if (cds.version.split('.')[0] < 9) return try { From 91aba5383e15f56d8b391096e4772566fc4d0fc8 Mon Sep 17 00:00:00 2001 From: D050513 Date: Thu, 21 May 2026 13:42:06 +0200 Subject: [PATCH 2/2] update deps, use Node.js-native SQLite --- package.json | 30 +++++++++++++++--------------- test/bookshop/package.json | 6 ++++++ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 8e0cf55d..d6feff90 100644 --- a/package.json +++ b/package.json @@ -29,28 +29,28 @@ "@opentelemetry/semantic-conventions": "^1.37" }, "peerDependencies": { - "@sap/cds": ">=8" + "@sap/cds": "^9" }, "devDependencies": { - "@cap-js/cds-test": ">=0", - "@cap-js/sqlite": ">=1", + "@cap-js/cds-test": "^0", + "@cap-js/sqlite": "^2.2", "@cap-js/telemetry": "file:.", - "@dynatrace/oneagent-sdk": "^1.5.0", - "@grpc/grpc-js": "^1.9.14", + "@dynatrace/oneagent-sdk": "^1.5", + "@grpc/grpc-js": "^1.9", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.205", "@opentelemetry/exporter-metrics-otlp-proto": "^0.205", "@opentelemetry/exporter-trace-otlp-grpc": "^0.205", "@opentelemetry/exporter-trace-otlp-proto": "^0.205", - "@opentelemetry/host-metrics": "^0.36.0", - "@opentelemetry/instrumentation-runtime-node": "^0.17.1", - "@sap/cds-mtxs": ">=2", - "axios": "^1.6.7", - "chai": "^4.4.1", - "chai-as-promised": "^7.1.1", - "chai-subset": "^1.6.0", - "eslint": "^9.35.0", - "express": "^4.21.2", - "jest": "^29.7.0" + "@opentelemetry/host-metrics": "^0.36", + "@opentelemetry/instrumentation-runtime-node": "^0.17", + "@sap/cds-mtxs": ">=3", + "axios": "^1.6", + "chai": "^4.4", + "chai-as-promised": "^7.1", + "chai-subset": "^1.6", + "eslint": "^10.4", + "express": "^5.2", + "jest": "^29.7" }, "cds": { "requires": { diff --git a/test/bookshop/package.json b/test/bookshop/package.json index 5b03536b..c9fa4d11 100644 --- a/test/bookshop/package.json +++ b/test/bookshop/package.json @@ -40,6 +40,9 @@ } } }, + "db": { + "driver": "node" + }, "messaging": { "kind": "local-messaging", "_kind": "file-based-messaging", @@ -84,5 +87,8 @@ "fiori": { "draft_deletion_timeout": false } + }, + "devDependencies": { + "@sap/cds-dk": "^9" } }