diff --git a/CHANGELOG.md b/CHANGELOG.md index db0d79dd..11808857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6843b802..ada6c599 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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` @@ -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":"","key":""}' +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. diff --git a/lib/metrics/index.js b/lib/metrics/index.js index 0dd12c0b..feee872b 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -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', @@ -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 + // 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 diff --git a/lib/tracing/index.js b/lib/tracing/index.js index 29105527..3ee12bd7 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -12,6 +12,7 @@ const { getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, + augmentCaaSCreds, hasDependency, _require } = require('../utils') @@ -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 + // 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) diff --git a/lib/utils.js b/lib/utils.js index f72c814e..bbb19859 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -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 + const pattern = new RegExp(mtls_service_pattern, 'i') + 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 + + // 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) + } + } else { + LOG._warn && LOG.warn('CaaS requires mTLS authentication. No mTLS credentials found. Bind a user-provided service with cert and key (base64 encoded).') + } +} + function augmentCLCreds(credentials) { if (credentials._augmented) return credentials._augmented = true @@ -203,6 +250,7 @@ module.exports = { getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, + augmentCaaSCreds, hasDependency, _hrnow, _require diff --git a/package.json b/package.json index ed20d4a5..de00fa36 100644 --- a/package.json +++ b/package.json @@ -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" + } + } } } } diff --git a/test/caas.test.js b/test/caas.test.js new file mode 100644 index 00000000..6febca45 --- /dev/null +++ b/test/caas.test.js @@ -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() + }) +})