Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
- Support for OpenTelemetry SDK 2.0
- `@opentelemetry/instrumentation-undici` added to the list of default instrumentations
- Support for `@sap/cds^10`
- Support for `telemetry-to-caas` kind for CaaS (Collector as a Service)

### Changed

Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Documentation can be found at [cap.cloud.sap](https://cap.cloud.sap/docs) and [o
- [`telemetry-to-console`](#telemetry-to-console)
- [`telemetry-to-dynatrace`](#telemetry-to-dynatrace)
- [`telemetry-to-cloud-logging`](#telemetry-to-cloud-logging)
- [`telemetry-to-caas`](#telemetry-to-caas)
- [`telemetry-to-jaeger`](#telemetry-to-jaeger)
- [`telemetry-to-otlp`](#telemetry-to-otlp)
- [Detailed Configuration Options](#detailed-configuration-options)
Expand Down Expand Up @@ -200,7 +201,7 @@ Please note that in order for logs to be exported via OpenTelemetry, `cds.log()`

## Predefined Kinds

There are five predefined kinds as follows:
There are six predefined kinds as follows:


### `telemetry-to-console`
Expand Down Expand Up @@ -283,12 +284,46 @@ 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 the tag `Cloud Logging`.

> Tip: To add the required tag to an existing user-provided service, you can use:
> 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-caas`

Exports traces and metrics to CaaS (Collector as a Service).
CaaS acts as a managed OpenTelemetry Collector that can route telemetry data to downstream backends like SAP Cloud Logging.

Use via `cds.requires.telemetry.kind = 'to-caas'`.

Required additional dependencies:
- `@opentelemetry/exporter-trace-otlp-proto`
- `@opentelemetry/exporter-metrics-otlp-proto`

CaaS requires mTLS authentication with SAP-signed certificates. You need to:

1. **Bind the CaaS service** to your app with subject/issuer configuration:
```yaml
# mta.yaml
requires:
- name: my-caas-instance
parameters:
config:
subject: "CN=my-app,..."
issuer: "CN=SAP PKI Certificate Service Client CA,..."
```

2. **Obtain and bind mTLS credentials** via a user-provided service containing `cert` and `key` (base64 encoded):
```bash
cf create-user-provided-service caas-mtls-creds -p '{"cert":"<base64>","key":"<base64>"}'
cf bind-service my-app caas-mtls-creds
```

The mTLS certificate must be created (e.g., via `openssl`) and signed through SAP BTP Certificate Service. The certificate must be renewed periodically (typically every 7 days).

> Note: The user-provided service name must match the pattern configured in `mtls_service_pattern` (default: `caas-mtls|caas-cert`).

### `telemetry-to-jaeger`

Exports traces to Jaeger.
Expand Down
14 changes: 13 additions & 1 deletion lib/metrics/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const {
PeriodicExportingMetricReader
} = require('@opentelemetry/sdk-metrics')

const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils')
const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, augmentCaaSCreds, _require } = require('../utils')

const _protocol2module = {
grpc: '@opentelemetry/exporter-metrics-otlp-grpc',
Expand Down Expand Up @@ -79,6 +79,18 @@ function _getExporter() {
config.credentials ??= credentials.credentials
}

if (kind.match(/to-caas$/)) {
if (!credentials) throw new Error('No CaaS credentials found. Make sure the app is bound to a caas-service instance.')
augmentCaaSCreds(credentials)
// Set OTLP env vars to binding credentials (prevents stale env values from taking precedence)
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = credentials.baseUrl

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: Same process-wide env var mutation as in tracing/index.jsOTEL_EXPORTER_OTLP_ENDPOINT is set to the base URL without a signal-specific path, which conflicts with the value set by the tracing exporter (or vice-versa depending on initialisation order). Use OTEL_EXPORTER_OTLP_METRICS_ENDPOINT instead.

Suggested change
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = credentials.baseUrl
process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = credentials.baseUrl + '/v1/metrics'

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

// Append /v1/metrics to base URL (OTLP exporter expects full URL when config.url is provided)
config.url = credentials.baseUrl + '/v1/metrics'
if (credentials.httpAgentOptions) {
config.httpAgentOptions = credentials.httpAgentOptions
}
}

const exporter = new metricsExporterModule[metricsExporter.class](config)
LOG._debug && LOG.debug('Using metrics exporter:', exporter)
return exporter
Expand Down
13 changes: 13 additions & 0 deletions lib/tracing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
getCredsForDTAsUPS,
getCredsForCLSAsUPS,
augmentCLCreds,
augmentCaaSCreds,
hasDependency,
_require
} = require('../utils')
Expand Down Expand Up @@ -126,6 +127,18 @@ function _getExporter() {
config.credentials ??= credentials.credentials
}

if (kind.match(/to-caas$/)) {
if (!credentials) throw new Error('No CaaS credentials found. Make sure the app is bound to a caas-service instance.')
augmentCaaSCreds(credentials)
// Set OTLP env vars to binding credentials (prevents stale env values from taking precedence)
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = credentials.baseUrl

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: process.env.OTEL_EXPORTER_OTLP_ENDPOINT is set to credentials.baseUrl (the path-less base URL) even though config.url is immediately set to the full /v1/traces URL. This env var mutation persists for the entire process lifetime and will be read by any subsequently initialised OTLP exporter (e.g. the metrics exporter called right after this), overwriting it again with the metrics-scoped base URL and potentially causing both exporters to send to the wrong endpoint. Consider removing this process.env mutation, or at minimum using the signal-specific env var (OTEL_EXPORTER_OTLP_TRACES_ENDPOINT).

Suggested change
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = credentials.baseUrl
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = credentials.baseUrl + '/v1/traces'

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

// Append /v1/traces to base URL (OTLP exporter expects full URL when config.url is provided)
config.url = credentials.baseUrl + '/v1/traces'
if (credentials.httpAgentOptions) {
config.httpAgentOptions = credentials.httpAgentOptions
}
}

const exporter = new tracingExporterModule[tracingExporter.class](config)
LOG._debug && LOG.debug('Using trace exporter:', exporter)

Expand Down
48 changes: 48 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,53 @@ function getCredsForCLSAsUPS() {
}
}

function getCredsForCaaSMtls() {
if (!process.env.VCAP_SERVICES) return
const vcap = JSON.parse(process.env.VCAP_SERVICES)
// Look for user-provided service with name matching configured pattern
const { mtls_service_pattern } = cds.env.requires.telemetry
if (!mtls_service_pattern) return

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: getCredsForCaaSMtls() reads cds.env.requires.telemetry.mtls_service_pattern but getCredsForCaaSMtls is only reached via augmentCaaSCreds, which is only called when kind matches to-caas. At that point cds.env.requires.telemetry is the merged kind config, so mtls_service_pattern should be present — but if for any reason the property is absent (e.g. user overrides the kind config without it), the function silently returns undefined and the else branch only logs a warning. The mTLS credentials silently become unconfigured rather than raising a clear error.

Suggested change
if (!mtls_service_pattern) return
if (!mtls_service_pattern) {
LOG._warn && LOG.warn('CaaS: mtls_service_pattern is not configured; cannot locate mTLS credentials.')
return
}

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

const pattern = new RegExp(mtls_service_pattern, 'i')

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: mtls_service_pattern is taken verbatim from user-supplied configuration and passed directly into new RegExp(...) without any escaping. A malformed pattern (e.g. "caas-mtls(") will throw a SyntaxError that propagates up uncaught and crashes the process during startup.

Suggested change
const pattern = new RegExp(mtls_service_pattern, 'i')
let pattern
try {
pattern = new RegExp(mtls_service_pattern, 'i')
} catch (e) {
LOG._error && LOG.error(`CaaS: Invalid mtls_service_pattern "${mtls_service_pattern}":`, e.message)
return
}

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

const mtlsCreds = vcap['user-provided']?.find(b => b.name.match(pattern))
if (mtlsCreds) return mtlsCreds.credentials
}

function augmentCaaSCreds(credentials) {
if (credentials._augmented) return
credentials._augmented = true

// check for otlp http endpoint
if (!credentials.otlp?.http) {
throw new Error('No OTLP HTTP endpoint found in CaaS binding. Make sure the CaaS instance is properly configured.')
}

// Store the base URL - path will be added per signal type (traces: /v1/traces, metrics: /v1/metrics)
credentials.baseUrl = credentials.otlp.http
// Also set url for backwards compatibility (without path - exporter will append it)
credentials.url = credentials.otlp.http
Comment thread
vkozyura marked this conversation as resolved.

// Check for mTLS credentials (cert + key) in user-provided service
const mtlsCreds = getCredsForCaaSMtls()
if (mtlsCreds && mtlsCreds.cert && mtlsCreds.key) {
try {
const cert = Buffer.from(mtlsCreds.cert, 'base64').toString('utf-8')
const key = Buffer.from(mtlsCreds.key, 'base64').toString('utf-8')

// Store the mTLS options for the exporter's httpAgentOptions
// The OTLP HTTP exporter will create an https.Agent with these options
credentials.httpAgentOptions = {
cert: cert,
key: key,
keepAlive: true
}
} catch (err) {
LOG._error && LOG.error('Failed to configure CaaS mTLS:', err.message)
}
Comment on lines +168 to +181

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: Buffer.from(..., 'base64').toString('utf-8') does not throw on invalid base64 input — it silently produces garbage bytes. If the mTLS setup error is then swallowed by the catch, the exporter will be configured with a corrupt certificate and will fail at connection time with a cryptic TLS error instead of a clear startup message.

Suggested change
try {
const cert = Buffer.from(mtlsCreds.cert, 'base64').toString('utf-8')
const key = Buffer.from(mtlsCreds.key, 'base64').toString('utf-8')
// Store the mTLS options for the exporter's httpAgentOptions
// The OTLP HTTP exporter will create an https.Agent with these options
credentials.httpAgentOptions = {
cert: cert,
key: key,
keepAlive: true
}
} catch (err) {
LOG._error && LOG.error('Failed to configure CaaS mTLS:', err.message)
}
const certRaw = Buffer.from(mtlsCreds.cert, 'base64').toString('utf-8')
const keyRaw = Buffer.from(mtlsCreds.key, 'base64').toString('utf-8')
if (!certRaw.includes('-----BEGIN') || !keyRaw.includes('-----BEGIN')) {
throw new Error('Decoded mTLS cert or key does not appear to be valid PEM data')
}
// Store the mTLS options for the exporter's httpAgentOptions
// The OTLP HTTP exporter will create an https.Agent with these options
credentials.httpAgentOptions = {
cert: certRaw,
key: keyRaw,
keepAlive: true
}
} catch (err) {
LOG._error && LOG.error('Failed to configure CaaS mTLS:', err.message)

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

} else {
LOG._warn && LOG.warn('CaaS requires mTLS authentication. No mTLS credentials found. Bind a user-provided service with cert and key (base64 encoded).')
}
}
Comment thread
vkozyura marked this conversation as resolved.

function augmentCLCreds(credentials) {
if (credentials._augmented) return
credentials._augmented = true
Expand Down Expand Up @@ -203,6 +250,7 @@ module.exports = {
getCredsForDTAsUPS,
getCredsForCLSAsUPS,
augmentCLCreds,
augmentCaaSCreds,
hasDependency,
_hrnow,
_require
Expand Down
18 changes: 18 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@
"metrics": {
"exporter": "env"
}
},
"telemetry-to-caas": {
"vcap": {
"label": "caas-service"
},
"mtls_service_pattern": "caas-mtls|caas-cert",
"tracing": {
"exporter": {
"module": "@opentelemetry/exporter-trace-otlp-proto",
"class": "OTLPTraceExporter"
}
},
"metrics": {
"exporter": {
"module": "@opentelemetry/exporter-metrics-otlp-proto",
"class": "OTLPMetricExporter"
}
}
}
}
}
Expand Down
128 changes: 128 additions & 0 deletions test/caas.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
const cds = require('@sap/cds')

// Mock VCAP_SERVICES for CaaS
const MOCK_CAAS_VCAP = {
'caas-service': [{
name: 'test-caas',
credentials: {
otlp: {
http: 'https://caas.example.com/otlp',
grpc: 'grpc://caas.example.com:4317'
}
}
}],
'user-provided': [{
name: 'caas-mtls-creds',
credentials: {
cert: Buffer.from('-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----').toString('base64'),
key: Buffer.from('-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----').toString('base64')
},
tags: []
}]
}

const MOCK_CAAS_VCAP_NO_MTLS = {
'caas-service': [{
name: 'test-caas',
credentials: {
otlp: {
http: 'https://caas.example.com/otlp',
grpc: 'grpc://caas.example.com:4317'
}
}
}]
}

describe('augmentCaaSCreds', () => {
let originalVcap

beforeAll(() => {
originalVcap = process.env.VCAP_SERVICES
})

afterAll(() => {
if (originalVcap) process.env.VCAP_SERVICES = originalVcap
else delete process.env.VCAP_SERVICES
})

beforeEach(() => {
cds.env.requires = cds.env.requires || {}
cds.env.requires.telemetry = { mtls_service_pattern: 'caas-mtls|caas-cert' }
delete require.cache[require.resolve('../lib/utils')]
})

test('sets baseUrl and url from otlp.http', () => {
process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP)
delete require.cache[require.resolve('../lib/utils')]
const { augmentCaaSCreds } = require('../lib/utils')

const credentials = {
otlp: {
http: 'https://caas.example.com/otlp',
grpc: 'grpc://caas.example.com:4317'
}
}

augmentCaaSCreds(credentials)

expect(credentials.baseUrl).toBe('https://caas.example.com/otlp')
expect(credentials.url).toBe('https://caas.example.com/otlp')
})

test('sets httpAgentOptions when mTLS credentials found', () => {
process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP)
delete require.cache[require.resolve('../lib/utils')]
const { augmentCaaSCreds } = require('../lib/utils')

const credentials = {
otlp: { http: 'https://caas.example.com/otlp' }
}

augmentCaaSCreds(credentials)

expect(credentials.httpAgentOptions).toBeDefined()
expect(credentials.httpAgentOptions.cert).toContain('BEGIN CERTIFICATE')
expect(credentials.httpAgentOptions.key).toContain('BEGIN PRIVATE KEY')
expect(credentials.httpAgentOptions.keepAlive).toBe(true)
})

test('throws when no OTLP endpoints', () => {
process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP)
delete require.cache[require.resolve('../lib/utils')]
const { augmentCaaSCreds } = require('../lib/utils')

expect(() => augmentCaaSCreds({})).toThrow('No OTLP HTTP endpoint found')
})

test('does not augment twice', () => {
process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP)
delete require.cache[require.resolve('../lib/utils')]
const { augmentCaaSCreds } = require('../lib/utils')

const credentials = {
otlp: { http: 'https://caas.example.com/otlp' }
}

augmentCaaSCreds(credentials)
const originalBaseUrl = credentials.baseUrl

credentials.otlp.http = 'https://different.com'
augmentCaaSCreds(credentials)

expect(credentials.baseUrl).toBe(originalBaseUrl)
})

test('no httpAgentOptions when mTLS credentials not found', () => {
process.env.VCAP_SERVICES = JSON.stringify(MOCK_CAAS_VCAP_NO_MTLS)
delete require.cache[require.resolve('../lib/utils')]
const { augmentCaaSCreds } = require('../lib/utils')

const credentials = {
otlp: { http: 'https://caas.example.com/otlp' }
}

augmentCaaSCreds(credentials)

expect(credentials.httpAgentOptions).toBeUndefined()
})
})