Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
19b08e7
bump otel deps to ^2.0
sjvans Aug 12, 2025
f6bd730
rm warning
sjvans Aug 12, 2025
a7023ef
disable version check
sjvans Aug 12, 2025
b3aaf01
new Resource() -> resourceFromAttributes()
sjvans Aug 12, 2025
e2aeae6
tracing
sjvans Aug 12, 2025
8a7df1a
metrics
sjvans Aug 12, 2025
2a90135
Merge branch 'main' into otel-2.0
sjvans Aug 12, 2025
1a7089f
rm otel<2 check
sjvans Aug 13, 2025
a2fa911
feat: replace access to otel env variables
PDT42 Aug 15, 2025
fd40b73
feat: replace deprecated core imports
PDT42 Aug 15, 2025
1dd8369
feat: adopt tracing sdk api changes replacing parentSpanId
PDT42 Aug 15, 2025
3c7d1c0
feat: pass a ViewOptions object with type
PDT42 Aug 15, 2025
0b682d2
Merge branch 'main' into otel-2.0
PDT42 Aug 15, 2025
6fd5731
feat: distinguish push & pull
PDT42 Aug 20, 2025
a5cdbf8
Merge branch 'main' into otel-2.0
PDT42 Aug 20, 2025
9f44b52
Merge branch 'main' into otel-2.0
PDT42 Sep 2, 2025
1e8e7e7
fix: add on handler to mocked remote service
PDT42 Sep 3, 2025
7b897a6
fix: add on handler to mocked remote service
PDT42 Sep 3, 2025
5c5709a
chore: integrate moder api
PDT42 Sep 3, 2025
ed97ff7
chore: integrate modern api
PDT42 Sep 3, 2025
ae432fc
Revert "chore: integrate modern api"
PDT42 Sep 3, 2025
4ee89ef
Revert "chore: integrate moder api"
PDT42 Sep 3, 2025
40780c5
Merge branch 'fix/adopt-unhandled-action-throws' into otel-2.0
PDT42 Sep 3, 2025
4acff8b
Merge branch 'main' into otel-2.0
PDT42 Sep 4, 2025
aae86f9
chore: bump dependency versions
PDT42 Sep 17, 2025
c5d15ff
feat: stop checking for calm delegates
PDT42 Sep 17, 2025
3538c4c
Merge branch 'main' into otel-2.0
PDT42 Sep 22, 2025
40ac5c5
Merge branch 'main' into otel-2.0
PDT42 Nov 27, 2025
15fad51
Merge branch 'main' into otel-2.0
sjvans Jun 22, 2026
3afe8b1
update deps
sjvans Jun 22, 2026
45f9491
ignore .claude/
sjvans Jun 22, 2026
83edc6b
add package-lock.json
sjvans Jun 22, 2026
e4f498d
switch to vitest
sjvans Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,5 @@ dist
test/msg-box
test/bookshop/gen
test/bookshop/.cdsrc.json

.claude/
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
36 changes: 0 additions & 36 deletions cds-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')()
})()
14 changes: 0 additions & 14 deletions jest.config.js

This file was deleted.

6 changes: 3 additions & 3 deletions lib/exporter/ConsoleSpanExporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require('https')
const path = require('path')

const { diag } = require('@opentelemetry/api')
const { getStringFromEnv, diagLogLevelFromString } = require('@opentelemetry/core')
const { registerInstrumentations } = require('@opentelemetry/instrumentation')

const tracing = require('./tracing')
Expand Down
30 changes: 11 additions & 19 deletions lib/logging/index.js
Original file line number Diff line number Diff line change
@@ -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')

Expand All @@ -20,15 +20,13 @@ 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' }
}

Expand Down Expand Up @@ -75,23 +73,17 @@ 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) ||
const logProcessor = _getCustomProcessor(exporter) ||
(process.env.NODE_ENV === 'production'
? new BatchLogRecordProcessor(exporter)
: new SimpleLogRecordProcessor(exporter))
loggerProvider.addLogRecordProcessor(logProcessor)

// TODO: CALM may have initialized a global provider already

const loggerProvider = new LoggerProvider({ resource, processors: [logProcessor]})
logs.setGlobalLoggerProvider(loggerProvider)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: LoggerProvider was previously initialized only when no global provider existed. Now it unconditionally calls logs.setGlobalLoggerProvider(loggerProvider) without checking whether a provider has already been set. The TODO comment acknowledges this, but the removed guard was load-bearing — if CALM (or any other module) sets a global provider first, this will silently overwrite it.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


cds.on('served', () => {
const loggers = {}
Expand Down
102 changes: 51 additions & 51 deletions lib/metrics/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@ 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')

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'
}
Expand All @@ -27,55 +26,59 @@ 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
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) {
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
const metricsExporterModule =
metricsExporter.module === '@cap-js/telemetry' ? require('../exporter') : _require(metricsExporter.module)
// 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 configruation depending on 'kind' of telementry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: "configruation" → "configuration" and "telementry" → "telemetry".

Suggested change
// Augment configruation depending on 'kind' of telementry
// Augment configuration depending on 'kind' of telemetry

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

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`)
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
}

Expand All @@ -85,39 +88,36 @@ module.exports = resource => {
/*
* general setup
*/
let meterProvider = metrics.getMeterProvider()
if (meterProvider.constructor.name === 'NoopMeterProvider') {
const dtmetadata = getDynatraceMetadata()
resource = new Resource({}).merge(resource).merge(dtmetadata)
const metricsConfig = cds.env.requires.telemetry.metrics.config
let exporter = _getExporter()

if (typeof exporter.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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: "wrappeed" → "wrapped".

Suggested change
// > The exporter needs to be wrappeed thus, to set an export interval
// > The exporter needs to be wrapped thus, to set an export interval

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

exporter = new PeriodicExportingMetricReader({ ...metricsConfig, exporter })
}

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 = []
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()
})
)
views.push({
meterName: "@cap-js/telemetry:host-metrics",
instrumentName: "system.*",
aggregation: {
type: AggregationType.DROP,
},
});
}
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.')
}

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)
}
// TODO: CALM may have initialized a global provider already

const meterProvider = new MeterProvider({ resource, readers: [exporter], views });
metrics.setGlobalMeterProvider(meterProvider);
Comment on lines +100 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: The indentation here is mismatched — lines 100–120 are indented as if inside a block, but they are actually at the top level of the module.exports function. While JS doesn't care about indentation, the metricsConfig variable (line 91) and exporter (line 92) are also at the outer level, making it clear the inner block was accidentally left indented. This is harmless but was likely a leftover from the removed if (meterProvider.constructor.name === 'NoopMeterProvider') block.

Suggested change
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 = []
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()
})
)
views.push({
meterName: "@cap-js/telemetry:host-metrics",
instrumentName: "system.*",
aggregation: {
type: AggregationType.DROP,
},
});
}
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.')
}
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)
}
// TODO: CALM may have initialized a global provider already
const meterProvider = new MeterProvider({ resource, readers: [exporter], views });
metrics.setGlobalMeterProvider(meterProvider);
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,
},
})
}
// TODO: CALM may have initialized a global provider already
const meterProvider = new MeterProvider({ resource, readers: [exporter], views })
metrics.setGlobalMeterProvider(meterProvider)

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


/*
* add individual metrics
Expand Down
Loading
Loading