Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
08837fe
Add telemetry-to-caas kind for CaaS (Collector as a Service) support
vkozyura Jun 22, 2026
79eaadc
Add certificate-service detection for CaaS mTLS
vkozyura Jun 22, 2026
12223ce
Add mTLS support for CaaS via caas-mtls-creds user-provided service
vkozyura Jun 22, 2026
2da163c
Fix mTLS config - use httpAgentOptions
vkozyura Jun 22, 2026
46b0129
Monkey-patch https.request for CaaS mTLS
vkozyura Jun 22, 2026
a10eb6b
Remove package-lock.json from tracking
vkozyura Jun 22, 2026
41f70d5
Use agentOptions for CaaS mTLS instead of monkey-patching
vkozyura Jun 22, 2026
6de322b
Fix mTLS config: use httpAgentOptions instead of agentOptions
vkozyura Jun 23, 2026
10140df
Fix OTLP URL: append signal paths (/v1/traces, /v1/metrics)
vkozyura Jun 23, 2026
d592028
Merge branch 'main' into feature/caas-support
vkozyura Jun 30, 2026
7aca1b4
rem hard-coded names and debug logs
vkozyura Jun 30, 2026
a3d0f5e
fix env vars
vkozyura Jun 30, 2026
00c635d
add readme, changelog and test
vkozyura Jun 30, 2026
e4dbdf6
small fix
vkozyura Jun 30, 2026
11efddb
use cds.env.requires.telemetry.x509 for certificate
vkozyura Jul 21, 2026
8d2eb24
add logging
vkozyura Jul 22, 2026
7c02946
add tracing to package.json
vkozyura Jul 22, 2026
e74ef96
fix test
vkozyura Jul 22, 2026
a7cfae0
Merge branch 'main' into feature/caas-support
vkozyura Aug 17, 2026
67a1488
Update lib/tracing/index.js
vkozyura Aug 17, 2026
566a6bb
Update lib/metrics/index.js
vkozyura Aug 17, 2026
f7e4e68
Update lib/logging/index.js
vkozyura Aug 17, 2026
4c8624e
Update lib/utils.js
vkozyura Aug 17, 2026
51cd815
remove backwars compat for credentials
vkozyura Aug 17, 2026
6e883e2
simplify mtlsCreds check
vkozyura Aug 17, 2026
a86df66
update warning
vkozyura Aug 17, 2026
7a39ade
readme
vkozyura Aug 17, 2026
e5bff75
testing certificate renewal w/o re-assigning env vars
vkozyura Aug 17, 2026
9967e8c
fix test
vkozyura Aug 17, 2026
0e61476
fix if no logging lib
vkozyura Aug 17, 2026
c160fbc
use ztis
vkozyura Aug 22, 2026
27a7049
fixes and logs
vkozyura Aug 22, 2026
019a8af
fix async call for ztis
vkozyura Aug 24, 2026
9385b52
continue fixing async
vkozyura Aug 24, 2026
dc693ac
fix 3 async
vkozyura Aug 24, 2026
a04454d
add debug info
vkozyura Aug 24, 2026
b5b2b76
debug: add wrapper-level logging to cds.js
vkozyura Aug 24, 2026
389651b
more debug
vkozyura Aug 24, 2026
aa00693
more debug
vkozyura Aug 24, 2026
9f3b757
experimenting
vkozyura Aug 24, 2026
f72dceb
debug
vkozyura Aug 24, 2026
52d7bea
fix
vkozyura Aug 24, 2026
778bb3a
fix2
vkozyura Aug 24, 2026
e0ee73e
use early TracerProvider for ZTIS
vkozyura Aug 24, 2026
e8ba74f
fix: detect ZTI by label and service key, not just tags
vkozyura Aug 24, 2026
baf2226
fix: use internal API for adding span processor in OTel v2.x
vkozyura Aug 24, 2026
e628d15
fix: use BufferingSpanProcessor to hold spans until ZTI creds ready
vkozyura Aug 24, 2026
dd2f6eb
fix: create metrics exporter AFTER ZTI ready to get mTLS creds
vkozyura Aug 24, 2026
11a8a77
cleanup: remove verbose comments and simplify ZTI code
vkozyura Aug 24, 2026
4d2cda5
refactor
vkozyura Aug 25, 2026
47ff244
fix after refactoring
vkozyura Aug 25, 2026
b8a3282
fix logging
vkozyura Aug 25, 2026
ddc952b
debug: add logging to _getLoggingConfig
vkozyura Aug 25, 2026
3e52ca9
fix 2 logging
vkozyura Aug 25, 2026
152b33e
fix 3 logging
vkozyura Aug 25, 2026
331fe75
revert fixes logging
vkozyura Aug 25, 2026
21f4646
fix logging again
vkozyura Aug 25, 2026
0873484
revert not needed changes in logging
vkozyura Aug 25, 2026
6412f19
fixing logging part 2
vkozyura Aug 25, 2026
fe1f474
continue fixing logging
vkozyura Aug 25, 2026
6bf599e
small refactoring
vkozyura Aug 25, 2026
b7fcd13
try out synchronous ztis
vkozyura Aug 25, 2026
e10257f
re-check creds on each export attempt
vkozyura Aug 25, 2026
89b2328
changelog and readme
vkozyura Aug 25, 2026
c604b64
revert refactoring and rewriting
vkozyura Aug 25, 2026
9cf86c3
fix logging
vkozyura Aug 25, 2026
6073734
refactor
vkozyura Aug 27, 2026
fec4ecf
fix try catch
vkozyura Aug 27, 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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 2.1.0

### Added

- Support for `telemetry-to-caas` kind for CaaS (Collector as a Service) with automatic mTLS certificate management and rotation via Zero Trust Identity Service (ZTI/SPIFFE). Falls back to base64-encoded certificates from environment variables when ZTI is not available or explicitly disabled.

## Version 2.0.1 - 2026-07-03

### Fixed
Expand Down
79 changes: 77 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,86 @@ 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, metrics, and logs 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`
- `@opentelemetry/exporter-logs-otlp-proto` (if using log export)

CaaS requires mTLS authentication. There are two ways to provide the mTLS certificates:

#### Option 1: Zero Trust Identity (ZTI) with SPIRE (Recommended)

ZTI with SPIRE sidecar automatically provisions and rotates mTLS certificates (SVID files). This is the recommended approach for production.

1. **Bind ZTI service** to your app:
```yaml
# mta.yaml
requires:
- name: my-zti-instance
```

2. **Bind CaaS service** to your app:
```yaml
# mta.yaml
requires:
- name: my-caas-instance
```

That's it! The plugin automatically detects ZTI and uses the SVID files provisioned by the SPIRE sidecar for mTLS authentication.

**How it works**: The SPIRE sidecar provisions SVID certificate files in parallel with app startup. Since these files may not exist immediately, `@cap-js/telemetry` uses a lazy exporter that buffers telemetry data until the credentials become available. Once the SVID files are ready, buffered data is flushed and subsequent telemetry is exported normally. Certificate rotation is handled automatically.

> **Note**: For optimal buffering behavior in production, ensure `NODE_ENV=production` is set. This enables batch processing with periodic export intervals (5s for traces/logs, 60s for metrics), ensuring buffered telemetry is flushed shortly after ZTI credentials become ready. In development mode, traces and logs use immediate export per-request.

To explicitly disable ZTI (e.g., for testing), set:
```bash
CDS_REQUIRES_TELEMETRY_USE_ZTI=false
```

#### Option 2: Manual Certificate Configuration (Fallback)

For environments without ZTI or when you want to manage certificates externally, you can provide mTLS credentials manually:

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. **Provide mTLS credentials** via environment variables (base64 encoded):
```yaml
# mta.yaml
properties:
CDS_REQUIRES_TELEMETRY_X509_CERT: '<base64-encoded-certificate-chain>'
CDS_REQUIRES_TELEMETRY_X509_KEY: '<base64-encoded-private-key>'
```

Or set directly via Cloud Foundry CLI:
```bash
cf set-env my-app CDS_REQUIRES_TELEMETRY_X509_CERT "<base64-cert>"
cf set-env my-app CDS_REQUIRES_TELEMETRY_X509_KEY "<base64-key>"
```

The mTLS certificate must be SAP-signed through the BTP Certificate Service. Certificates can be created with validity from 7 days up to 1 year and must be renewed before expiration. For detailed certificate setup and renewal instructions, refer to your project's certificate management documentation.

### `telemetry-to-jaeger`

Exports traces to Jaeger.
Expand Down
92 changes: 92 additions & 0 deletions lib/exporter/LazyExporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
const cds = require('@sap/cds')

const MAX_BUFFER_SIZE = 1000

function _tryCreateExporter(state, createExporter, LOG) {
if (state._exporter) return
if (state._createFailed) return

const result = createExporter()

// Transient: credentials not ready yet, will retry
if (result.status === 'not_ready') return

// Permanent: configuration error, stop retrying
if (result.status === 'error') {
LOG._warn && LOG.warn('Lazy exporter: failed to create exporter:', result.error)
state._createFailed = true
return
}

// Success
state._exporter = result.exporter
}

function _flushBuffer(state) {
if (state._buffer.length === 0 || !state._exporter) return

const buffered = state._buffer
state._buffer = []

for (const item of buffered) {
if (Array.isArray(item)) {
state._exporter.export(item, () => {})
} else {
state._exporter.export(item, () => {})
}
}
}

// Buffers telemetry until mTLS credentials are available (for CaaS with ZTI)
function createLazyExporter(createExporter, options = {}) {
const LOG = cds.log('telemetry')
const maxBufferSize = options.maxBufferSize || MAX_BUFFER_SIZE

const state = {
_exporter: null,
_buffer: [],
_createFailed: false
}

return {
export(items, resultCallback) {
_tryCreateExporter(state, createExporter, LOG)

if (state._exporter) {
_flushBuffer(state)
state._exporter.export(items, resultCallback)
} else {
if (state._buffer.length >= maxBufferSize) {
state._buffer.shift()
LOG._warn && LOG.warn('Lazy exporter: buffer full, dropping telemetry')
}
state._buffer.push(items)
resultCallback({ code: 0 })
}
},

shutdown() {
state._buffer = []
return state._exporter?.shutdown() ?? Promise.resolve()
},

forceFlush() {
_tryCreateExporter(state, createExporter, LOG)
if (state._exporter) {
_flushBuffer(state)
return state._exporter.forceFlush?.() ?? Promise.resolve()
}
return Promise.resolve()
},

_getBufferSize() {
return state._buffer.length
},

_isExporterCreated() {
return state._exporter !== null
}
}
}

module.exports = { createLazyExporter }
103 changes: 69 additions & 34 deletions lib/logging/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ const LOG = cds.log('telemetry')

const { getStringFromEnv } = require('@opentelemetry/core')

const { getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils')
const { getCredsForCLSAsUPS, augmentCLCreds, augmentCaaSCreds, getCredsForCaaSMtls, decodeCredentials, _require } = require('../utils')
const { createLazyExporter } = require('../exporter/LazyExporter')

const _protocol2module = {
grpc: '@opentelemetry/exporter-logs-otlp-grpc',
Expand Down Expand Up @@ -33,7 +34,13 @@ function _getExporter() {
}

// use _require for better error message
const loggingExporterModule = _require(loggingExporter.module)
let loggingExporterModule
try {
loggingExporterModule = _require(loggingExporter.module)
} catch {
LOG._warn && LOG.warn(`Logs exporter module '${loggingExporter.module}' not found. Logging telemetry will be disabled.`)
return null
}
if (!loggingExporterModule[loggingExporter.class])
throw new Error(`Unknown logs exporter "${loggingExporter.class}" in module "${loggingExporter.module}"`)
const config = { ...(loggingExporter.config || {}) }
Expand All @@ -46,6 +53,27 @@ function _getExporter() {
config.credentials ??= credentials.credentials
}

if (kind === 'telemetry-to-caas') {
if (!credentials) throw new Error('No CaaS credentials found.')

augmentCaaSCreds(credentials)

const lazyExporter = createLazyExporter(() => {
const mtlsCreds = getCredsForCaaSMtls()
if (mtlsCreds.status === 'not_ready') return { status: 'not_ready' }
if (mtlsCreds.status === 'disabled') return { status: 'error', error: 'CaaS mTLS credentials not configured' }

const { cert, key } = decodeCredentials(mtlsCreds)
const exporterConfig = {
...config,
url: credentials.baseUrl + '/v1/logs',
httpAgentOptions: { cert, key, keepAlive: true }
}
return { status: 'ready', exporter: new loggingExporterModule[loggingExporter.class](exporterConfig) }
})
return lazyExporter
}

const exporter = new loggingExporterModule[loggingExporter.class](config)
LOG._debug && LOG.debug('Using logs exporter:', exporter)

Expand Down Expand Up @@ -76,9 +104,44 @@ module.exports = resource => {
const { logs, SeverityNumber } = require('@opentelemetry/api-logs')
const { LoggerProvider, BatchLogRecordProcessor, SimpleLogRecordProcessor } = require('@opentelemetry/sdk-logs')

/*
* create processor
*/
const exporter = _getExporter()
if (!exporter) return null
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)

// setup logs interception via cds.log.format
cds.on('served', () => {
const loggerProvider = logs.getLoggerProvider()
// Must be done AFTER LoggerProvider is set globally.
let _logInterceptionSetup = false
const setupLogInterception = () => {
if (_logInterceptionSetup) return
_logInterceptionSetup = true

const loggers = {}
const l2s = { 1: 'ERROR', 2: 'WARN', 3: 'INFO', 4: 'DEBUG', 5: 'TRACE' }
Expand Down Expand Up @@ -125,37 +188,9 @@ module.exports = resource => {

// replace format function of existing loggers
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)
cds.on('served', setupLogInterception)

return loggerProvider
}
24 changes: 23 additions & 1 deletion lib/metrics/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const { getStringFromEnv } = require('@opentelemetry/core')
const { resourceFromAttributes } = require('@opentelemetry/resources')
const { AggregationTemporality, MeterProvider, PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics')

const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils')
const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, augmentCaaSCreds, getCredsForCaaSMtls, decodeCredentials, _require } = require('../utils')
const { createLazyExporter } = require('../exporter/LazyExporter')

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

if (kind === 'telemetry-to-caas') {
if (!credentials) throw new Error('No CaaS credentials found.')

augmentCaaSCreds(credentials)

const lazyExporter = createLazyExporter(() => {
const mtlsCreds = getCredsForCaaSMtls()
if (mtlsCreds.status === 'not_ready') return { status: 'not_ready' }
if (mtlsCreds.status === 'disabled') return { status: 'error', error: 'CaaS mTLS credentials not configured' }

const { cert, key } = decodeCredentials(mtlsCreds)
const exporterConfig = {
...config,
url: credentials.baseUrl + '/v1/metrics',
httpAgentOptions: { cert, key, keepAlive: true }
}
return { status: 'ready', exporter: new metricsExporterModule[metricsExporter.class](exporterConfig) }
})
return lazyExporter
}

const exporter = new metricsExporterModule[metricsExporter.class](config)
LOG._debug && LOG.debug('Using metrics exporter:', exporter)
return exporter
Expand Down
Loading