Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, 2]

env:
NPM_CONFIG_IGNORE_SCRIPTS: true
Expand All @@ -26,7 +26,8 @@ jobs:
fail-fast: false
matrix:
node-version: [24.x, 22.x]
cds-version: [9, 8]
# cds-version: [10, 9]
cds-version: [9]
steps:
- uses: actions/checkout@v6.0.3

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.

Bug: The actions/checkout@v6.0.3 reference is invalid — the latest major release is v4. This will cause the workflow to fail with a "not found" error.

Suggested change
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v4

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

- name: Use Node.js ${{ matrix.node-version }}
Expand All @@ -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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,5 @@ dist
# test
test/msg-box
test/bookshop/gen
test/bookshop/.cdsrc.json

.claude/
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - tbd

### 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
Expand Down
26 changes: 14 additions & 12 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 Expand Up @@ -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`.
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -395,6 +393,10 @@ Default:
"http": {
"module": "@opentelemetry/instrumentation-http",
"class": "HttpInstrumentation"
},
"undici": {
"module": "@opentelemetry/instrumentation-undici",
"class": "UndiciInstrumentation"
}
}
```
Expand Down
40 changes: 4 additions & 36 deletions cds-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')()
})()
2 changes: 1 addition & 1 deletion lib/exporter/ConsoleMetricExporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
47 changes: 30 additions & 17 deletions lib/exporter/ConsoleSpanExporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ const {
ATTR_CODE_COLUMN_NUMBER
} = require('@opentelemetry/semantic-conventions')

const _padded = v =>
`${`${v}`.split('.')[0].padStart(3, ' ')}.${(`${v}`.split('.')[1] || '0').padEnd(2, '0').substring(0, 2)}`
// Format a number as `___.dd` (3-char integer slot, 2-decimal fraction, rounded).
// Note: prior implementation truncated the fractional part via string-substring, which read
// `1.567 ms` as `1.56 ms` — toFixed rounds.
const _padded = v => {
const [int, frac] = Number(v).toFixed(2).split('.')
return `${int.padStart(3, ' ')}.${frac}`
}

const _span2line = (span, parentStartTime = 0) => {
if (span.name.match(/^[A-Z]+ \/\${0,1}\w+$/)) return ''
const _span2line = (span, parentStartTime = 0, indent = '') => {
// Skip http-instrumentation spans that consist of method + path (e.g. `GET /foo`, `POST /odata/v4/admin/Books`).
// These are folded into the root via the name-adjustment in lib/tracing/trace.js.
if (span.name.match(/^[A-Z]+ \/\S*$/)) return ''

const start = parentStartTime ? hrTimeToMilliseconds(span.startTime) - hrTimeToMilliseconds(parentStartTime) : 0
const duration = hrTimeToMilliseconds(span.duration)
Expand All @@ -28,7 +35,7 @@ const _span2line = (span, parentStartTime = 0) => {
name = name + ' ' + (span.attributes[ATTR_URL_PATH] || span.attributes['http.target'] || 'unknown')
if (name.length > 80) name = name.substring(0, 79) + '…'

result += ' ' + (span.___indent || '') + name
result += ' ' + indent + name

// REVISIT: what is this for?
if (span.attributes[ATTR_CODE_FILE_PATH] !== undefined) {
Expand Down Expand Up @@ -56,15 +63,18 @@ const _span_sorter = (a, b) => {
return hrTimeToMilliseconds(b.endTime) - hrTimeToMilliseconds(a.endTime) //> the one ending later should be printed first
}

const _list2tree = (span, spans, flat, indent) => {
// Walks the span tree depth-first and pushes `{ span, indent }` pairs into `out` in print order.
// The indent string is carried via the recursion, not stored on the spans — ReadableSpan instances
// are conceptually immutable post-`end()`, so we don't mutate them here.
const _list2tree = (span, spans, out, 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)
const childIndent = indent + ' '
for (const each of children) {
each.___indent = indent + ' '
flat.push(each)
_list2tree(each, spans, flat, each.___indent)
out.push({ span: each, indent: childIndent })
_list2tree(each, spans, out, childIndent)
}
}

Expand All @@ -88,6 +98,9 @@ class ConsoleSpanExporter /* implements SpanExporter */ {
* Shutdown the exporter.
*/
shutdown() {
// REVISIT: pending children in `_temporaryStorage` whose root never arrived are silently
// dropped here. Consider flushing remaining buckets as separate primers (or at least logging
// a debug message noting how much data was lost) once we decide on the UX for partial traces.
this._sendSpans([])
return Promise.resolve()
}
Expand All @@ -99,22 +112,22 @@ 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) {
const w3c_parent_id = cds.context?.http?.req?.headers?.traceparent?.split('-')[2]
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))
// Build a flat, depth-first list of { span, indent } in print order.
const flat = []
reqs.sort(_span_sorter)
for (const each of reqs) {
each.___indent = ' '
flat.push(each)
_list2tree(each, children, flat, each.___indent)
flat.push({ span: each, indent: ' ' })
_list2tree(each, children, flat, ' ')
}
for (const each of flat) toLog += _span2line(each, span.startTime)
for (const { span: s, indent } of flat) toLog += _span2line(s, span.startTime, indent)
this._temporaryStorage.delete(span.spanContext().traceId)
}
LOG.info(toLog)
Expand Down
57 changes: 41 additions & 16 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -62,34 +89,32 @@ 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,
loggerProvider,
instrumentations: _getInstrumentations()
})
}

function setup_with_calm() {
// setup tracing, metrics, and logging
tracing()
metrics()
if (cds.env.requires.telemetry.logging) logging()
}
Comment on lines +113 to +118

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.

Bug: setup_with_calm calls tracing(), metrics(), and logging() without registering instrumentations via registerInstrumentations. In setup_standalone, instrumentations are explicitly registered after the providers are set up. The CALM path silently skips all instrumentations (e.g., HttpInstrumentation, UndiciInstrumentation).


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


module.exports = hasDependency('@sap/xotel-agent-ext-js') ? setup_with_calm : setup_standalone
Loading