From f2fcd52c1e27d2ca0542e7cf195e97383f4fbad3 Mon Sep 17 00:00:00 2001 From: Karsten Schnitter Date: Thu, 27 Aug 2026 13:28:09 +0200 Subject: [PATCH 1/3] Move PEM parsing to generic credentials class Parsing the PEM representation of certs and keys is universal. This refactoring can be used for other more generic exporters. Signed-off-by: Karsten Schnitter --- .../ext/binding/CloudFoundryCredentials.java | 6 ++++ .../ext/exporter/CloudLoggingCredentials.java | 16 ++------- .../binding/CloudFoundryCredentialsTest.java | 34 +++++++++++++++++++ 3 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentialsTest.java diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java index f08cc7c0..53efd691 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java @@ -1,5 +1,6 @@ package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; +import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.TreeMap; @@ -15,6 +16,11 @@ public String getString(String key) { return properties.get(key); } + public byte[] getPEMBytes(String key) { + String raw = getString(key); + return raw == null ? null : raw.trim().replace("\\n", "\n").getBytes(StandardCharsets.UTF_8); + } + public static Builder builder() { return new Builder(); } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java index 634ac46f..cbb67f45 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/CloudLoggingCredentials.java @@ -2,7 +2,6 @@ import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; -import java.nio.charset.StandardCharsets; import java.util.logging.Logger; public class CloudLoggingCredentials { @@ -29,15 +28,6 @@ static CloudLoggingCredentials.Parser parser() { return PARSER; } - private static byte[] getPEMBytes(CloudFoundryCredentials credentials, String key) { - String raw = credentials.getString(key); - return getPEMBytes(raw); - } - - private static byte[] getPEMBytes(String raw) { - return raw == null ? null : raw.trim().replace("\\n", "\n").getBytes(StandardCharsets.UTF_8); - } - private static boolean isBlank(String text) { return text == null || text.trim().isEmpty(); } @@ -94,9 +84,9 @@ CloudLoggingCredentials parse(CloudFoundryCredentials cfCredentials) { CloudLoggingCredentials parsed = new CloudLoggingCredentials(); String rawEndpoint = cfCredentials.getString(CRED_OTLP_ENDPOINT); parsed.endpoint = isBlank(rawEndpoint) ? null : CLOUD_LOGGING_ENDPOINT_PREFIX + rawEndpoint; - parsed.clientKey = getPEMBytes(cfCredentials, CRED_OTLP_CLIENT_KEY); - parsed.clientCert = getPEMBytes(cfCredentials, CRED_OTLP_CLIENT_CERT); - parsed.serverCert = getPEMBytes(cfCredentials, CRED_OTLP_SERVER_CERT); + parsed.clientKey = cfCredentials.getPEMBytes(CRED_OTLP_CLIENT_KEY); + parsed.clientCert = cfCredentials.getPEMBytes(CRED_OTLP_CLIENT_CERT); + parsed.serverCert = cfCredentials.getPEMBytes(CRED_OTLP_SERVER_CERT); return parsed; } } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentialsTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentialsTest.java new file mode 100644 index 00000000..2d73c332 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentialsTest.java @@ -0,0 +1,34 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class CloudFoundryCredentialsTest { + + private static final String VALID_CERT = + "-----BEGIN CERTIFICATE-----\n" + "Base-64-Encoded Certificate\n" + "-----END CERTIFICATE-----\n"; + + private static final String VALID_KEY = + "-----BEGIN PRIVATE KEY-----\n" + "Base-64-Encoded Private Key\n" + "-----END PRIVATE KEY-----\n"; + + @Test + void providesStrings() { + CloudFoundryCredentials credentials = CloudFoundryCredentials.builder().add("some-key", "some-value").build(); + + assertThat(credentials.getString("some-key")).isEqualTo("some-value"); + assertThat(credentials.getString("other-key")).isNull(); + assertThat(credentials.getPEMBytes("some-key")).isEqualTo("some-value".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void formatsPEMBytes() { + CloudFoundryCredentials credentials = + CloudFoundryCredentials.builder().add("cert", VALID_CERT).add("key", VALID_KEY).build(); + + assertThat(new String(credentials.getPEMBytes("cert"), StandardCharsets.UTF_8)).isEqualTo(VALID_CERT.trim()); + assertThat(new String(credentials.getPEMBytes("key"), StandardCharsets.UTF_8)).isEqualTo(VALID_KEY.trim()); + } +} From 8b27f6e674944072b478d83dfd6a390db3ef19f6 Mon Sep 17 00:00:00 2001 From: Karsten Schnitter Date: Sun, 30 Aug 2026 13:58:19 +0200 Subject: [PATCH 2/3] Support finding CF Service by Name Add the option to filter CF Services by their name, not only tag and label. Signed-off-by: Karsten Schnitter --- .../binding/CloudFoundryServicesAdapter.java | 27 ++++++++++++++++--- .../CloudFoundryServicesAdapterTest.java | 8 ++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java index e5b8b503..fa9f26b4 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapter.java @@ -40,8 +40,7 @@ public CloudFoundryServicesAdapter() { /** * Stream CfServices, that match the provided properties. Empty or null values are interpreted as not applicable. No - * check will be performed during search. User-provided service instances will be preferred unless the - * {@code userProvidedLabel is null or empty. Provided only null values will return all service instances. + * check will be performed during search. Provided only null values will return all service instances. * * @param serviceLabels * the labels of services @@ -50,6 +49,23 @@ public CloudFoundryServicesAdapter() { * @return a stream of service instances present in the CloudFoundry environment variable VCAP_SERVICES */ Stream stream(List serviceLabels, List serviceTags) { + return stream(serviceLabels, serviceTags, null); + } + + /** + * Stream CfServices, that match the provided properties. Empty or null values are interpreted as not applicable. No + * check will be performed during search. Provided only null values will return all service instances. + * + * @param serviceLabels + * the labels of services + * @param serviceTags + * the tags of services + * @param serviceName + * the name of the service + * @return a stream of service instances present in the CloudFoundry environment variable VCAP_SERVICES + */ + Stream stream(List serviceLabels, List serviceTags, + String serviceName) { if (vcapServicesJson == null) { LOG.info("No environment variable " + VCAP_SERVICES + " found. Skipping service binding detection."); return Stream.empty(); @@ -62,7 +78,8 @@ Stream stream(List serviceLabels, List { if (serviceInstance.getName() != null) { - if (hasServiceTag(serviceTags, serviceInstance.getTags())) { + if (hasServiceName(serviceName, serviceInstance.getName()) && hasServiceTag(serviceTags, + serviceInstance.getTags())) { services.add(serviceInstance); } } @@ -168,6 +185,10 @@ private boolean hasServiceTag(List requiredTags, List instanceTa return instanceTags.containsAll(requiredTags); } + private boolean hasServiceName(String requiredName, String actualName) { + return requiredName == null || requiredName.isEmpty() || requiredName.equals(actualName); + } + private Comparator byLabels(List serviceLabels) { return (l, r) -> getIndex(serviceLabels, l.getLabel()) - getIndex(serviceLabels, r.getLabel()); } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java index 90ff5a88..d3e43123 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryServicesAdapterTest.java @@ -110,6 +110,14 @@ void filtersBySingleTag(CloudFoundryServicesAdapter adapter) { "ups-find-me1", "ups-find-me2"); } + @ParameterizedTest + @MethodSource("adapters") + void filtersByName(CloudFoundryServicesAdapter adapter) { + List services = + adapter.stream(emptyList(), emptyList(), "managed-find-me1").collect(toList()); + assertServiceNames(services).containsExactly("managed-find-me1"); + } + @ParameterizedTest @MethodSource("adapters") void standardUseCase(CloudFoundryServicesAdapter adapter) { From 3f46e7f4341f4cb49f67a3df44e855b6e38e927b Mon Sep 17 00:00:00 2001 From: Karsten Schnitter Date: Wed, 2 Sep 2026 13:13:47 +0200 Subject: [PATCH 3/3] Add signal exporters "vcap-service" for generic CF bindings This change introduces new exporters for logs, metrics and traces that are freely configurable to read the connection credentials from any CF binding in VCAP_SERVICES. The binding can be specified by name, label, tags or a combination of those. The exporters needs configuration of the properties within the credentials of the binding to know where to find the endpoint and auth properties. The exporter require either client key and cert for mTLS and will trust any server cert if not provided. Without mTLS an authorisation header is required. --- ...logs.ConfigurableLogRecordExporterProvider | 2 + .../README.md | 131 ++++++++ .../ext/binding/CloudFoundryCredentials.java | 2 +- .../ext/binding/VcapServiceProvider.java | 33 ++ .../ext/config/ExtensionConfigurations.java | 253 ++++++++++++++- .../ext/exporter/VcapServiceCredentials.java | 111 +++++++ .../VcapServiceLogsExporterProvider.java | 163 ++++++++++ .../VcapServiceMetricsExporterProvider.java | 219 +++++++++++++ .../VcapServiceSpanExporterProvider.java | 161 ++++++++++ ...logs.ConfigurableLogRecordExporterProvider | 3 +- ...metrics.ConfigurableMetricExporterProvider | 3 +- ...pi.traces.ConfigurableSpanExporterProvider | 3 +- .../ext/binding/VcapServiceProviderTest.java | 93 ++++++ .../exporter/VcapServiceCredentialsTest.java | 102 ++++++ .../VcapServiceLogsExporterProviderTest.java | 226 ++++++++++++++ ...capServiceMetricsExporterProviderTest.java | 292 ++++++++++++++++++ .../VcapServiceSpanExporterProviderTest.java | 231 ++++++++++++++ 17 files changed, 2020 insertions(+), 8 deletions(-) create mode 100644 META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProvider.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentials.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProvider.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProvider.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProvider.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProviderTest.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentialsTest.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProviderTest.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProviderTest.java create mode 100644 cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProviderTest.java diff --git a/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider b/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider new file mode 100644 index 00000000..7d051562 --- /dev/null +++ b/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider @@ -0,0 +1,2 @@ +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.CloudLoggingLogsExporterProvider +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.VcapServiceLogsExporterProvider diff --git a/cf-java-logging-support-opentelemetry-agent-extension/README.md b/cf-java-logging-support-opentelemetry-agent-extension/README.md index 4c0ce2e8..527e8dc9 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/README.md +++ b/cf-java-logging-support-opentelemetry-agent-extension/README.md @@ -12,6 +12,7 @@ The extension provides the following main features: * auto-configuration of the generic OpenTelemetry OTLP exporter to SAP Collector as a Service (CaaS) or [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) * additional exporters for logs, metrics and traces for [SAP Cloud Logging](https://discovery-center.cloud.sap/serviceCatalog/cloud-logging) * additional exporter for metrics for [Dynatrace](https://docs.dynatrace.com/docs/setup-and-configuration/setup-on-container-platforms/cloud-foundry/deploy-oneagent-on-sap-cloud-platform-for-application-only-monitoring) +* **generic `vcap-service` exporter** for logs, metrics and traces to any OTLP-compatible endpoint configured via a CF service binding * adding resource attributes describing the CF application * filtering span attributes by name before export @@ -202,6 +203,34 @@ The following table summarizes all configuration properties provided by the exte | `sap.dynatrace.cf.binding.label.value` | The label value used to identify managed Dynatrace service bindings. | `dynatrace` | | `sap.dynatrace.cf.binding.tag.value` | The tag value used to identify managed Dynatrace service bindings. | `dynatrace` | | `sap.dynatrace.cf.binding.token.name` | The name of the field containing the Dynatrace API token within the service binding credentials. | | +| `sap.vcap-service.cf.binding.name` | The name of the CF service binding to use for the `vcap-service` exporter. When not set, the first binding matching the label/tag filters is used. | | +| `sap.vcap-service.cf.binding.label.value` | The service label used to filter CF service bindings for the `vcap-service` exporter. When not set, any label is accepted. | | +| `sap.vcap-service.cf.binding.tag.value` | The service tag used to filter CF service bindings for the `vcap-service` exporter. When not set, any tag is accepted. | | +| `sap.vcap-service.cf.binding.credentials.otlp.endpoint` | The name of the credential field that holds the OTLP endpoint URL. Used as fallback for all signals when the signal-specific keys are not set. | | +| `sap.vcap-service.cf.binding.credentials.otlp.logs.endpoint` | The name of the credential field that holds the OTLP logs endpoint URL. Falls back to `otlp.endpoint`. | _(from `otlp.endpoint`)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.metrics.endpoint` | The name of the credential field that holds the OTLP metrics endpoint URL. Falls back to `otlp.endpoint`. | _(from `otlp.endpoint`)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.traces.endpoint` | The name of the credential field that holds the OTLP traces endpoint URL. Falls back to `otlp.endpoint`. | _(from `otlp.endpoint`)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.auth-token` | The name of the credential field that holds the authentication token. | | +| `sap.vcap-service.cf.binding.credentials.otlp.auth-header-name` | The HTTP header name used to send the authentication token. | `Authorization` | +| `sap.vcap-service.cf.binding.credentials.otlp.client-key` | The name of the credential field that holds the mTLS client private key in PEM format. | | +| `sap.vcap-service.cf.binding.credentials.otlp.client-cert` | The name of the credential field that holds the mTLS client certificate in PEM format. | | +| `sap.vcap-service.cf.binding.credentials.otlp.server-cert` | The name of the credential field that holds the trusted server CA certificate in PEM format. When not set and mTLS is used, the certificate is downloaded from the endpoint automatically. | | +| `otel.exporter.vcap-service.compression` | The compression algorithm to use for the `vcap-service` exporter. Applies to all signals unless overridden per signal. | `gzip` | +| `otel.exporter.vcap-service.protocol` | The transport protocol for the `vcap-service` exporter: `http/protobuf` or `grpc`. Applies to all signals unless overridden per signal. | `http/protobuf` | +| `otel.exporter.vcap-service.timeout` | The maximum duration to wait when exporting data. Applies to all signals unless overridden per signal. | _(OTel SDK default)_ | +| `otel.exporter.vcap-service.logs.compression` | Compression for logs. Falls back to `otel.exporter.vcap-service.compression`. | _(from `vcap-service.compression`)_ | +| `otel.exporter.vcap-service.logs.protocol` | Transport protocol for logs. Falls back to `otel.exporter.vcap-service.protocol`. | _(from `vcap-service.protocol`)_ | +| `otel.exporter.vcap-service.logs.timeout` | Export timeout for logs. Falls back to `otel.exporter.vcap-service.timeout`. | _(from `vcap-service.timeout`)_ | +| `otel.exporter.vcap-service.metrics.compression` | Compression for metrics. Falls back to `otel.exporter.vcap-service.compression`. | _(from `vcap-service.compression`)_ | +| `otel.exporter.vcap-service.metrics.default.histogram.aggregation` | The default histogram aggregation for metrics. Delegates to the underlying OTLP exporter. | _(OTel SDK default)_ | +| `otel.exporter.vcap-service.metrics.exclude.names` | A comma-separated list of metric name patterns to be excluded. Wildcard `*` is only supported at the end of the name. Applied after the include filter. | | +| `otel.exporter.vcap-service.metrics.include.names` | A comma-separated list of metric name patterns to be included. Wildcard `*` is only supported at the end of the name. When not set, all metrics are exported. | | +| `otel.exporter.vcap-service.metrics.protocol` | Transport protocol for metrics. Falls back to `otel.exporter.vcap-service.protocol`. | _(from `vcap-service.protocol`)_ | +| `otel.exporter.vcap-service.metrics.temporality.preference` | The preferred aggregation temporality for metrics: `cumulative`, `delta`, or `lowmemory`. | `cumulative` | +| `otel.exporter.vcap-service.metrics.timeout` | Export timeout for metrics. Falls back to `otel.exporter.vcap-service.timeout`. | _(from `vcap-service.timeout`)_ | +| `otel.exporter.vcap-service.traces.compression` | Compression for traces. Falls back to `otel.exporter.vcap-service.compression`. | _(from `vcap-service.compression`)_ | +| `otel.exporter.vcap-service.traces.protocol` | Transport protocol for traces. Falls back to `otel.exporter.vcap-service.protocol`. | _(from `vcap-service.protocol`)_ | +| `otel.exporter.vcap-service.traces.timeout` | Export timeout for traces. Falls back to `otel.exporter.vcap-service.timeout`. | _(from `vcap-service.timeout`)_ | ## Using User-Provided Service Instances @@ -264,6 +293,108 @@ SAP_DYNATRACE_CF_BINDING_TOKEN_NAME= java #... ``` +## Generic OTLP Service Binding Exporter (vcap-service) + +_This feature was introduced with version 4.2.0 of the extension._ + +The `vcap-service` exporter lets you ship logs, metrics, and traces to **any OTLP-compatible endpoint** that is described by a Cloud Foundry service binding. +Unlike the `cloud-logging` or `dynatrace` exporters, it does not require a specific service type. +The names of the credential fields that carry the endpoint URL, TLS certificates, and authentication token are freely configurable via environment variables. + +### Enabling the vcap-service Exporter + +Select it per signal with the standard OTel exporter environment variables: + +```sh +export OTEL_LOGS_EXPORTER=vcap-service +export OTEL_METRICS_EXPORTER=vcap-service +export OTEL_TRACES_EXPORTER=vcap-service +``` + +Multiple exporters can be combined with comma separation, e.g., `OTEL_LOGS_EXPORTER=cloud-logging,vcap-service`. + +### Service Binding Format + +The exporter reads the OTLP connection details from the credentials of a CF service binding. +It is compatible with both managed and user-provided service instances. +The credential field names are not fixed — you tell the exporter which field to read via configuration (see [Credential Mapping](#credential-mapping) below). + +A minimal user-provided service binding using token authentication looks like this: + +```json +{ + "user-provided": [{ + "name": "my-otlp-service", + "label": "user-provided", + "tags": [], + "credentials": { + "otlp-logs-endpoint": "https://collector.example.com/v1/logs", + "otlp-metrics-endpoint": "https://collector.example.com/v1/metrics", + "otlp-traces-endpoint": "https://collector.example.com/v1/traces", + "auth-token": "Bearer my-token" + } + }] +} +``` + +A binding using mTLS instead of token authentication would provide three additional fields (client key, client certificate, and optionally the server CA certificate). + +### Selecting the Service Binding + +Use the following properties to tell the exporter which binding to use: + +| Property | Env variable | Description | Default | +|---|---|---|---| +| `sap.vcap-service.cf.binding.name` | `SAP_VCAP_SERVICE_CF_BINDING_NAME` | Exact name of the service binding. | _(first matching binding)_ | +| `sap.vcap-service.cf.binding.label.value` | `SAP_VCAP_SERVICE_CF_BINDING_LABEL_VALUE` | Filter by service label (e.g. `user-provided`). | _(any label)_ | +| `sap.vcap-service.cf.binding.tag.value` | `SAP_VCAP_SERVICE_CF_BINDING_TAG_VALUE` | Filter by service tag. | _(any tag)_ | + +At least `sap.vcap-service.cf.binding.name` or one of the filter properties should be set, otherwise the first binding in `VCAP_SERVICES` is picked. + +### Credential Mapping + +The following properties name the credential field that holds each piece of connection information. +Setting a property to the value `my-field` means the exporter reads `credentials["my-field"]` from the chosen service binding. + +| Property | Env variable | Description | Default | +|---|---|---|---| +| `sap.vcap-service.cf.binding.credentials.otlp.endpoint` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_ENDPOINT` | Credential field that holds the OTLP endpoint URL (fallback for all signals). | _(required unless signal-specific keys are set)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.logs.endpoint` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_LOGS_ENDPOINT` | Credential field for the OTLP logs endpoint URL. Falls back to `otlp.endpoint`. | _(falls back to `otlp.endpoint`)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.metrics.endpoint` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_METRICS_ENDPOINT` | Credential field for the OTLP metrics endpoint URL. Falls back to `otlp.endpoint`. | _(falls back to `otlp.endpoint`)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.traces.endpoint` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_TRACES_ENDPOINT` | Credential field for the OTLP traces endpoint URL. Falls back to `otlp.endpoint`. | _(falls back to `otlp.endpoint`)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.auth-token` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_AUTH_TOKEN` | Credential field that holds the authentication token. Not required when mTLS is used. | _(no token authentication)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.auth-header-name` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_AUTH_HEADER_NAME` | Name of the HTTP header used to send the token. | `Authorization` | +| `sap.vcap-service.cf.binding.credentials.otlp.client-key` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_CLIENT_KEY` | Credential field that holds the mTLS client private key in PEM format. | _(no mTLS)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.client-cert` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_CLIENT_CERT` | Credential field that holds the mTLS client certificate in PEM format. | _(no mTLS)_ | +| `sap.vcap-service.cf.binding.credentials.otlp.server-cert` | `SAP_VCAP_SERVICE_CF_BINDING_CREDENTIALS_OTLP_SERVER_CERT` | Credential field that holds the trusted server CA certificate in PEM format. When not set and mTLS is used, the extension downloads the certificate from the endpoint automatically. | _(auto-download when mTLS is configured)_ | + +**Endpoint URL format**: The endpoint URL must include the full path for `http/protobuf` (e.g. `https://collector.example.com/v1/logs`). For `grpc`, provide the host and port without a path (e.g. `https://collector.example.com:443`). + +**Authentication**: Exactly one of token authentication or mTLS must be configured. When a token field is set, mTLS credential fields are ignored. + +### Export Settings + +General settings apply to all signals. Per-signal settings take precedence when set. + +| Property | Description | Default | +|---|---|---| +| `otel.exporter.vcap-service.protocol` | Transport protocol: `http/protobuf` or `grpc`. | `http/protobuf` | +| `otel.exporter.vcap-service.compression` | Compression: `gzip` or `none`. | `gzip` | +| `otel.exporter.vcap-service.timeout` | Export timeout (e.g. `10000ms`, `30s`). | _(OTel SDK default)_ | +| `otel.exporter.vcap-service.logs.protocol` | Protocol for logs. Falls back to `vcap-service.protocol`. | _(from general)_ | +| `otel.exporter.vcap-service.logs.compression` | Compression for logs. Falls back to `vcap-service.compression`. | _(from general)_ | +| `otel.exporter.vcap-service.logs.timeout` | Timeout for logs. Falls back to `vcap-service.timeout`. | _(from general)_ | +| `otel.exporter.vcap-service.metrics.protocol` | Protocol for metrics. Falls back to `vcap-service.protocol`. | _(from general)_ | +| `otel.exporter.vcap-service.metrics.compression` | Compression for metrics. Falls back to `vcap-service.compression`. | _(from general)_ | +| `otel.exporter.vcap-service.metrics.timeout` | Timeout for metrics. Falls back to `vcap-service.timeout`. | _(from general)_ | +| `otel.exporter.vcap-service.metrics.temporality.preference` | Aggregation temporality: `cumulative`, `delta`, or `lowmemory`. | `cumulative` | +| `otel.exporter.vcap-service.metrics.default.histogram.aggregation` | Default histogram aggregation. Delegates to the OTLP exporter. | _(OTel SDK default)_ | +| `otel.exporter.vcap-service.metrics.include.names` | Comma-separated metric name patterns to include. Wildcard `*` supported at end. | _(all metrics)_ | +| `otel.exporter.vcap-service.metrics.exclude.names` | Comma-separated metric name patterns to exclude. Wildcard `*` supported at end. Applied after include filter. | _(none excluded)_ | +| `otel.exporter.vcap-service.traces.protocol` | Protocol for traces. Falls back to `vcap-service.protocol`. | _(from general)_ | +| `otel.exporter.vcap-service.traces.compression` | Compression for traces. Falls back to `vcap-service.compression`. | _(from general)_ | +| `otel.exporter.vcap-service.traces.timeout` | Timeout for traces. Falls back to `vcap-service.timeout`. | _(from general)_ | + ## Implementation Differences between Cloud-Logging and OTLP Exporter The `cloud-logging` exporter provided by this extension is a facade for the `OtlpGrpcExporter` provided by the OpenTelemetry Java Agent, just like the `otlp` exporter. diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java index 53efd691..3fd40a18 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/CloudFoundryCredentials.java @@ -13,7 +13,7 @@ private CloudFoundryCredentials(Builder builder) { } public String getString(String key) { - return properties.get(key); + return key == null ? null : properties.get(key); } public byte[] getPEMBytes(String key) { diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProvider.java new file mode 100644 index 00000000..ebbdfeb0 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProvider.java @@ -0,0 +1,33 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.RUNTIME; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +public class VcapServiceProvider implements Supplier { + + private final CloudFoundryServiceInstance service; + + public VcapServiceProvider(ConfigProperties config) { + this(config, CloudFoundryServicesAdapter.builder().build()); + } + + VcapServiceProvider(ConfigProperties config, CloudFoundryServicesAdapter adapter) { + String label = RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.LABEL.getValue(config); + String tag = RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.TAG.getValue(config); + String name = RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.NAME.getValue(config); + + List serviceLabels = label != null ? List.of(label) : Collections.emptyList(); + List serviceTags = tag != null ? List.of(tag) : Collections.emptyList(); + + this.service = adapter.stream(serviceLabels, serviceTags, name).findFirst().orElse(null); + } + + @Override + public CloudFoundryServiceInstance get() { + return service; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java index 5c97be43..246c3a3b 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/config/ExtensionConfigurations.java @@ -175,6 +175,149 @@ interface METRICS { } } + + interface VCAP_SERVICE { + interface GENERAL { + + /** + *

Parses {@code otel.exporter.vcap-service.compression}.

+ *

The compression algorithm to use when exporting data to a service binding. Default is + * {@code "gzip"}.

+ */ + ConfigProperty COMPRESSION = + stringValued("otel.exporter.vcap-service.compression").withDefaultValue("gzip").build(); + /** + *

Parses {@code otel.exporter.vcap-service.protocol}.

+ *

The protocol to use when exporting data to a service binding. Default is + * {@code "http/protobuf"}.

+ */ + ConfigProperty PROTOCOL = + stringValued("otel.exporter.vcap-service.protocol").withDefaultValue("http/protobuf").build(); + + /** + *

Parses {@code otel.exporter.vcap-service.timeout}.

+ *

The maximum duration to wait for a service binding when exporting data.

+ */ + ConfigProperty TIMEOUT = durationValued("otel.exporter.vcap-service.timeout").build(); + } + + interface LOGS { + + /** + *

Parses {@code otel.exporter.vcap-service.logs.compression}.

+ *

The compression algorithm to use when exporting logs to a service binding. Falls back to + * {@code otel.exporter.vcap-service.compression} if not set.

+ */ + ConfigProperty COMPRESSION = + stringValued("otel.exporter.vcap-service.logs.compression").withFallback( + EXPORTER.VCAP_SERVICE.GENERAL.COMPRESSION).build(); + /** + *

Parses {@code otel.exporter.vcap-service.logs.protocol}.

+ *

The protocol to use when exporting logs to a service binding. Falls back to + * {@code otel.exporter.vcap-service.protocol} if not set.

+ */ + ConfigProperty PROTOCOL = stringValued("otel.exporter.vcap-service.logs.protocol").withFallback( + EXPORTER.VCAP_SERVICE.GENERAL.PROTOCOL).build(); + /** + *

Parses {@code otel.exporter.vcap-service.logs.timeout}.

+ *

The maximum duration to wait for a service binding when exporting logs. Falls back to + * {@code otel.exporter.vcap-service.timeout} if not set.

+ */ + ConfigProperty TIMEOUT = + durationValued("otel.exporter.vcap-service.logs.timeout").withFallback(GENERAL.TIMEOUT).build(); + } + + interface TRACES { + + /** + *

Parses {@code otel.exporter.vcap-service.traces.compression}.

+ *

The compression algorithm to use when exporting traces to a service binding. Falls back to + * {@code otel.exporter.vcap-service.compression} if not set.

+ */ + ConfigProperty COMPRESSION = + stringValued("otel.exporter.vcap-service.traces.compression").withFallback( + EXPORTER.VCAP_SERVICE.GENERAL.COMPRESSION).build(); + /** + *

Parses {@code otel.exporter.vcap-service.traces.protocol}.

+ *

The protocol to use when exporting traces to a service binding. Falls back to + * {@code otel.exporter.vcap-service.protocol} if not set.

+ */ + ConfigProperty PROTOCOL = + stringValued("otel.exporter.vcap-service.traces.protocol").withFallback( + EXPORTER.VCAP_SERVICE.GENERAL.PROTOCOL).build(); + /** + *

Parses {@code otel.exporter.vcap-service.traces.timeout}.

+ *

The maximum duration to wait for a service binding when exporting traces. Falls back to + * {@code otel.exporter.vcap-service.timeout} if not set.

+ */ + ConfigProperty TIMEOUT = + durationValued("otel.exporter.vcap-service.traces.timeout").withFallback(GENERAL.TIMEOUT) + .build(); + } + + interface METRICS { + + /** + *

Parses {@code otel.exporter.vcap-service.metrics.compression}.

+ *

The compression algorithm to use when exporting metrics to a service binding. Falls back to + * {@code otel.exporter.vcap-service.compression} if not set.

+ */ + ConfigProperty COMPRESSION = + stringValued("otel.exporter.vcap-service.metrics.compression").withFallback( + EXPORTER.VCAP_SERVICE.GENERAL.COMPRESSION).build(); + + /** + *

Parses {@code otel.exporter.vcap-service.metrics.default.histogram.aggregation}.

+ *

The default histogram aggregation for metrics exported to the service binding. Delegates to + * the underlying OTLP exporter, supporting all its configurations.

+ */ + ConfigProperty DEFAULT_HISTOGRAM_AGGREGATION = + stringValued("otel.exporter.vcap-service.metrics.default.histogram.aggregation").build(); + + /** + *

Parses {@code otel.exporter.vcap-service.metrics.exclude.names}.

+ *

A comma-separated list of metric name patterns to be excluded. Wildcard "*" is only supported + * at the end of the name. If not set, no metrics are excluded.

+ */ + ConfigProperty> EXCLUDE_NAMES = + listValued("otel.exporter.vcap-service.metrics.exclude.names").build(); + + /** + *

Parses {@code otel.exporter.vcap-service.metrics.include.names}.

+ *

A comma-separated list of metric name patterns to be included. Wildcard "*" is only supported + * at the end of the name. If not set, all metrics are exported.

+ */ + ConfigProperty> INCLUDE_NAMES = + listValued("otel.exporter.vcap-service.metrics.include.names").build(); + + /** + *

Parses {@code otel.exporter.vcap-service.metrics.protocol}.

+ *

The protocol to use when exporting metrics to a service binding. Falls back to + * {@code otel.exporter.vcap-service.protocol} if not set.

+ */ + ConfigProperty PROTOCOL = + stringValued("otel.exporter.vcap-service.metrics.protocol").withFallback( + EXPORTER.VCAP_SERVICE.GENERAL.PROTOCOL).build(); + + /** + *

Parses {@code otel.exporter.vcap-service.metrics.temporality.preference}.

+ *

The preferred aggregation temporality for metrics. Can be either {@code "cumulative"}, + * {@code "delta"}, or {@code "lowmemory"}. Default is {@code "cumulative"}.

+ */ + ConfigProperty TEMPORALITY_PREFERENCE = + stringValued("otel.exporter.vcap-service.metrics.temporality.preference").withDefaultValue( + "cumulative").build(); + + /** + *

Parses {@code otel.exporter.vcap-service.metrics.timeout}.

+ *

The maximum duration to wait for a service binding when exporting metrics. Falls back to + * {@code otel.exporter.vcap-service.timeout} if not set.

+ */ + ConfigProperty TIMEOUT = + durationValued("otel.exporter.vcap-service.metrics.timeout").withFallback(GENERAL.TIMEOUT) + .build(); + } + } } interface EXTENSION { @@ -193,8 +336,7 @@ interface FILTER { *

Parses * {@code sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.exclude.names}.

*

A comma-separated list of span attribute name patterns to be excluded when sanitizing - * span - * attributes. Wildcard "*" is only supported at the end of the name. If not set, no span + * span attributes. Wildcard "*" is only supported at the end of the name. If not set, no span * attributes are excluded.

*/ ConfigProperty> EXCLUDE_NAMES = listValued( @@ -203,8 +345,8 @@ interface FILTER { /** *

Parses * {@code sap.cf.integration.otel.extension.sanitizer.span.attribute.filter.include.names}.

- *

A comma-separated list of span attribute name patterns to be included when sanitizing span - * attributes. Wildcard "*" is only supported at the end of the name. If not set, all span + *

A comma-separated list of span attribute name patterns to be included when sanitizing + * span attributes. Wildcard "*" is only supported at the end of the name. If not set, all span * attributes are included.

*/ ConfigProperty> INCLUDE_NAMES = listValued( @@ -294,6 +436,109 @@ interface DYNATRACE { DEPRECATED.RUNTIME.CLOUD_FOUNDRY.SERVICE.DYNATRACE.TOKEN_NAME_OTEL).build(); } + interface VCAP_SERVICE { + + /** + *

Parses {@code sap.vcap-service.cf.binding.label.value}.

+ *

The label value used to identify the generic VCAP service binding. When not set, any + * label is accepted.

+ */ + ConfigProperty LABEL = stringValued("sap.vcap-service.cf.binding.label.value").build(); + + /** + *

Parses {@code sap.vcap-service.cf.binding.name}.

+ *

The name of the generic VCAP service binding to use. When not set, the first matching + * binding is used.

+ */ + ConfigProperty NAME = stringValued("sap.vcap-service.cf.binding.name").build(); + + /** + *

Parses {@code sap.vcap-service.cf.binding.tag.value}.

+ *

The tag value used to identify the generic VCAP service binding. When not set, any + * tag is accepted.

+ */ + ConfigProperty TAG = stringValued("sap.vcap-service.cf.binding.tag.value").build(); + + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.auth-header-name}.

+ *

The key within the service binding credentials whose value contains the name of the HTTP + * header to use for the authentication token. Default is {@code "Authorization"}. The header is + * only added when an auth-token is provided.

+ */ + ConfigProperty AUTH_HEADER_NAME = stringValued( + "sap.vcap-service.cf.binding.credentials.otlp.auth-header-name").withDefaultValue( + "Authorization").build(); + + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.*}.

+ *

The keys within the service binding credentials that should be used to configure the OTLP + * connection.

+ */ + interface CREDENTIALS { + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.endpoint}.

+ *

The key within the service binding credentials whose value contains OTLP endpoint + * URL.

+ */ + ConfigProperty OTLP_ENDPOINT = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.endpoint").build(); + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.logs.endpoint}.

+ *

The key within the service binding credentials whose value contains the OTLP logs + * endpoint URL. Falls back to {@link #OTLP_ENDPOINT} when not set.

+ */ + ConfigProperty OTLP_LOGS_ENDPOINT = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.logs.endpoint") + .withFallback(OTLP_ENDPOINT).build(); + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.metrics.endpoint}.

+ *

The key within the service binding credentials whose value contains the OTLP metrics + * endpoint URL. Falls back to {@link #OTLP_ENDPOINT} when not set.

+ */ + ConfigProperty OTLP_METRICS_ENDPOINT = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.metrics.endpoint") + .withFallback(OTLP_ENDPOINT).build(); + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.traces.endpoint}.

+ *

The key within the service binding credentials whose value contains the OTLP traces + * endpoint URL. Falls back to {@link #OTLP_ENDPOINT} when not set.

+ */ + ConfigProperty OTLP_TRACES_ENDPOINT = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.traces.endpoint") + .withFallback(OTLP_ENDPOINT).build(); + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.client-key}.

+ *

The key within the service binding credentials whose value contains the client key in PEM + * format.

+ */ + ConfigProperty OTLP_CLIENT_KEY = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.client-key").build(); + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.client-cert}.

+ *

The key within the service binding credentials whose value contains the client + * certificate in PEM format.

+ */ + ConfigProperty OTLP_CLIENT_CERT = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.client-cert").build(); + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.server-cert}.

+ *

The key within the service binding credentials whose value contains the server + * certificate in PEM format. This can be a CA that signed the server certificate. Leave empty + * when the certificate should be downloaded from the endpoint instead.

+ */ + ConfigProperty OTLP_SERVER_CERT = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.server-cert").build(); + /** + *

Parses {@code sap.vcap-service.cf.binding.credentials.otlp.auth-token}.

+ *

The key within the service binding credentials whose value contains the authentication + * token for the OTLP endpoint. This is optional and can be left empty if no (additional) + * authentication is required.

+ */ + ConfigProperty AUTH_TOKEN = + stringValued("sap.vcap-service.cf.binding.credentials.otlp.auth-token").build(); + } + + } } } } diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentials.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentials.java new file mode 100644 index 00000000..d6e54d5c --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentials.java @@ -0,0 +1,111 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ConfigProperty; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +import java.util.function.Function; +import java.util.logging.Logger; + +import static com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY; + +public class VcapServiceCredentials { + + private static final Logger LOG = Logger.getLogger(VcapServiceCredentials.class.getName()); + private final String endpoint; + private final byte[] clientKey; + private final byte[] clientCert; + private final byte[] serverCert; + private final String authToken; + + public VcapServiceCredentials(String endpoint, byte[] clientKey, byte[] clientCert, byte[] serverCert, + String authToken) { + this.endpoint = endpoint; + this.clientKey = clientKey; + this.clientCert = clientCert; + this.serverCert = serverCert; + this.authToken = authToken; + } + + public String getEndpoint() { + return endpoint; + } + + public byte[] getClientKey() { + return clientKey; + } + + public byte[] getClientCert() { + return clientCert; + } + + public byte[] getServerCert() { + return serverCert; + } + + public String getAuthToken() { + return authToken; + } + + public boolean validate() { + if (endpoint == null || endpoint.trim().isEmpty()) { + LOG.warning( + "Generic VCAP service credential property for the endpoint is not configured. Skipping credential parsing."); + return false; + } + if (authToken != null && !authToken.trim().isEmpty()) { + return true; + } + if (clientKey == null || clientKey.length == 0 || clientCert == null || clientCert.length == 0) { + LOG.warning( + "Generic VCAP service credential properties for the client key or certificate missing or incomplete. Skipping credential parsing."); + return false; + } + return true; + } + + static Function parser(ConfigProperties config) { + return parser(config, CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_ENDPOINT); + } + + static Function parser(ConfigProperties config, + ConfigProperty endpointProperty) { + String endpointName = endpointProperty.getValue(config); + String clientKeyName = CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_CLIENT_KEY.getValue(config); + String clientCertName = CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_CLIENT_CERT.getValue(config); + String serverCertName = CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_SERVER_CERT.getValue(config); + String authTokenName = CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.AUTH_TOKEN.getValue(config); + if (endpointName == null) { + LOG.info( + "Generic VCAP service credential property \"" + CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_ENDPOINT.getKey() + "\" is not configured. Skipping credential parsing."); + return ignored -> null; + } + return new Parser(endpointName, clientKeyName, clientCertName, serverCertName, authTokenName); + } + + static class Parser implements Function { + private final String endpointName; + private final String clientKeyName; + private final String clientCertName; + private final String serverCertName; + private final String authTokenName; + + private Parser(String endpointName, String clientKeyName, String clientCertName, String serverCertName, + String authTokenName) { + this.endpointName = endpointName; + this.clientKeyName = clientKeyName; + this.clientCertName = clientCertName; + this.serverCertName = serverCertName; + this.authTokenName = authTokenName; + } + + @Override + public VcapServiceCredentials apply(CloudFoundryCredentials cloudFoundryCredentials) { + return new VcapServiceCredentials(cloudFoundryCredentials.getString(endpointName), + cloudFoundryCredentials.getPEMBytes(clientKeyName), + cloudFoundryCredentials.getPEMBytes(clientCertName), + cloudFoundryCredentials.getPEMBytes(serverCertName), + cloudFoundryCredentials.getString(authTokenName)); + } + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProvider.java new file mode 100644 index 00000000..b8e978ae --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProvider.java @@ -0,0 +1,163 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.VcapServiceProvider; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXPORTER; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.RUNTIME; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporterBuilder; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporterBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider; +import io.opentelemetry.sdk.common.export.RetryPolicy; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.logging.Logger; + +import static java.lang.String.format; + +public class VcapServiceLogsExporterProvider implements ConfigurableLogRecordExporterProvider { + + private static final Logger LOG = Logger.getLogger(VcapServiceLogsExporterProvider.class.getName()); + private static final String MSK_SKIP = " Skipping logs exporter registration."; + private static final String MSG_UNSUPPORTED_PROTOCOL = + "Unsupported protocol \"%s\" for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_NO_BINDING = + "No generic service binding found matching the configured criteria." + MSK_SKIP; + private static final String MSG_NO_CREDENTIALS = "No credentials found for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_INVALID_CREDENTIALS = + "Invalid credentials found for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_MISSING_SERVER_CERT = + "No server certificate provided for service binding \"%s\" (%s). Attempting to download the server certificate from the endpoint."; + private static final String MSG_FAIL_SERVER_CERT_DOWNLOAD = + "Failed to download server certificate for service binding \"%s\" (%s)." + MSK_SKIP; + + private final Function serviceProvider; + private final Function> + credentialParserProvider; + private final ServerCertificateDownloader serverCertificateDownloader; + + public VcapServiceLogsExporterProvider() { + this(config -> new VcapServiceProvider(config).get(), + config -> VcapServiceCredentials.parser(config, RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_LOGS_ENDPOINT), + new ServerCertificateDownloader()); + } + + VcapServiceLogsExporterProvider(Function serviceProvider, + Function> credentialParserProvider, + ServerCertificateDownloader serverCertificateDownloader) { + this.serviceProvider = serviceProvider; + this.credentialParserProvider = credentialParserProvider; + this.serverCertificateDownloader = serverCertificateDownloader; + } + + private static void addTimeout(Consumer setter, ConfigProperties config) { + Duration timeout = EXPORTER.VCAP_SERVICE.LOGS.TIMEOUT.getValue(config); + if (timeout != null) { + setter.accept(timeout); + } + } + + private static void addAuthTokenHeader(BiConsumer setter, ConfigProperties config, + VcapServiceCredentials credentials) { + if (credentials.getAuthToken() != null) { + String headerName = RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.AUTH_HEADER_NAME.getValue(config); + setter.accept(headerName, credentials.getAuthToken()); + } + } + + @Override + public String getName() { + return "vcap-service"; + } + + @Override + public LogRecordExporter createExporter(ConfigProperties config) { + CloudFoundryServiceInstance serviceInstance = serviceProvider.apply(config); + if (serviceInstance == null) { + LOG.info(MSG_NO_BINDING); + return NoopLogRecordExporter.getInstance(); + } + CloudFoundryCredentials rawCredentials = serviceInstance.getCredentials(); + if (rawCredentials == null) { + LOG.warning(format(MSG_NO_CREDENTIALS, serviceInstance.getName(), serviceInstance.getLabel())); + return NoopLogRecordExporter.getInstance(); + } + VcapServiceCredentials credentials = credentialParserProvider.apply(config).apply(rawCredentials); + if (credentials == null || !credentials.validate()) { + LOG.warning(format(MSG_INVALID_CREDENTIALS, serviceInstance.getName(), serviceInstance.getLabel())); + return NoopLogRecordExporter.getInstance(); + } + + switch (EXPORTER.VCAP_SERVICE.LOGS.PROTOCOL.getValue(config)) { + case "http/protobuf": + return createHttpProtobufLogRecordExporter(config, credentials, serviceInstance); + case "grpc": + return createGrpcLogRecordExporter(config, credentials, serviceInstance); + default: + LOG.warning(format(MSG_UNSUPPORTED_PROTOCOL, EXPORTER.VCAP_SERVICE.LOGS.PROTOCOL.getValue(config), + serviceInstance.getName(), serviceInstance.getLabel())); + + } + return NoopLogRecordExporter.getInstance(); + } + + private LogRecordExporter createGrpcLogRecordExporter(ConfigProperties config, VcapServiceCredentials credentials, + CloudFoundryServiceInstance serviceInstance) { + OtlpGrpcLogRecordExporterBuilder builder = + OtlpGrpcLogRecordExporter.builder().setEndpoint(credentials.getEndpoint()) + .setCompression(EXPORTER.VCAP_SERVICE.LOGS.COMPRESSION.getValue(config)) + .setRetryPolicy(RetryPolicy.getDefault()); + addTimeout(builder::setTimeout, config); + if (!addTlsConfig(builder::setClientTls, builder::setTrustedCertificates, credentials, + s -> format(s, serviceInstance.getName(), serviceInstance.getLabel()))) { + return NoopLogRecordExporter.getInstance(); + } + addAuthTokenHeader(builder::addHeader, config, credentials); + return builder.build(); + } + + private LogRecordExporter createHttpProtobufLogRecordExporter(ConfigProperties config, + VcapServiceCredentials credentials, + CloudFoundryServiceInstance serviceInstance) { + OtlpHttpLogRecordExporterBuilder builder = + OtlpHttpLogRecordExporter.builder().setEndpoint(credentials.getEndpoint()) + .setCompression(EXPORTER.VCAP_SERVICE.LOGS.COMPRESSION.getValue(config)) + .setRetryPolicy(RetryPolicy.getDefault()); + addTimeout(builder::setTimeout, config); + if (!addTlsConfig(builder::setClientTls, builder::setTrustedCertificates, credentials, + s -> format(s, serviceInstance.getName(), serviceInstance.getLabel()))) { + return NoopLogRecordExporter.getInstance(); + } + addAuthTokenHeader(builder::addHeader, config, credentials); + return builder.build(); + } + + private boolean addTlsConfig(BiConsumer clientTls, Consumer serverCert, + VcapServiceCredentials credentials, Function messageFormatter) { + if (credentials.getClientKey() != null && credentials.getClientCert() != null) { + clientTls.accept(credentials.getClientKey(), credentials.getClientCert()); + if (credentials.getServerCert() != null) { + serverCert.accept(credentials.getServerCert()); + } else { + LOG.info(messageFormatter.apply(MSG_MISSING_SERVER_CERT)); + String serverCertificate = serverCertificateDownloader.download(credentials.getEndpoint()); + if (serverCertificate != null) { + serverCert.accept(serverCertificate.getBytes(StandardCharsets.UTF_8)); + } else { + LOG.warning(messageFormatter.apply(MSG_FAIL_SERVER_CERT_DOWNLOAD)); + return false; + } + } + } + return true; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProvider.java new file mode 100644 index 00000000..81ce8029 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProvider.java @@ -0,0 +1,219 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.VcapServiceProvider; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXPORTER; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.RUNTIME; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporterBuilder; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporterBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; +import io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider; +import io.opentelemetry.sdk.common.export.RetryPolicy; +import io.opentelemetry.sdk.metrics.Aggregation; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.export.AggregationTemporalitySelector; +import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.internal.aggregator.AggregationUtil; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.logging.Logger; + +import static io.opentelemetry.sdk.metrics.Aggregation.explicitBucketHistogram; +import static java.lang.String.format; + +public class VcapServiceMetricsExporterProvider implements ConfigurableMetricExporterProvider { + + private static final Logger LOG = Logger.getLogger(VcapServiceMetricsExporterProvider.class.getName()); + private static final String MSK_SKIP = " Skipping metrics exporter registration."; + private static final String MSG_UNSUPPORTED_PROTOCOL = + "Unsupported protocol \"%s\" for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_NO_BINDING = + "No generic service binding found matching the configured criteria." + MSK_SKIP; + private static final String MSG_NO_CREDENTIALS = "No credentials found for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_INVALID_CREDENTIALS = + "Invalid credentials found for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_MISSING_SERVER_CERT = + "No server certificate provided for service binding \"%s\" (%s). Attempting to download the server certificate from the endpoint."; + private static final String MSG_FAIL_SERVER_CERT_DOWNLOAD = + "Failed to download server certificate for service binding \"%s\" (%s)." + MSK_SKIP; + + private final Function serviceProvider; + private final Function> + credentialParserProvider; + private final ServerCertificateDownloader serverCertificateDownloader; + + public VcapServiceMetricsExporterProvider() { + this(config -> new VcapServiceProvider(config).get(), + config -> VcapServiceCredentials.parser(config, RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_METRICS_ENDPOINT), + new ServerCertificateDownloader()); + } + + VcapServiceMetricsExporterProvider( + Function serviceProvider, + Function> credentialParserProvider, + ServerCertificateDownloader serverCertificateDownloader) { + this.serviceProvider = serviceProvider; + this.credentialParserProvider = credentialParserProvider; + this.serverCertificateDownloader = serverCertificateDownloader; + } + + private static void addTimeout(Consumer setter, ConfigProperties config) { + Duration timeout = EXPORTER.VCAP_SERVICE.METRICS.TIMEOUT.getValue(config); + if (timeout != null) { + setter.accept(timeout); + } + } + + private static void addAuthTokenHeader(BiConsumer setter, ConfigProperties config, + VcapServiceCredentials credentials) { + if (credentials.getAuthToken() != null) { + String headerName = RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.AUTH_HEADER_NAME.getValue(config); + setter.accept(headerName, credentials.getAuthToken()); + } + } + + private static AggregationTemporalitySelector getAggregationTemporalitySelector(ConfigProperties config) { + String temporalityStr = EXPORTER.VCAP_SERVICE.METRICS.TEMPORALITY_PREFERENCE.getValue(config); + switch (temporalityStr.toLowerCase(Locale.ROOT)) { + case "cumulative": + return AggregationTemporalitySelector.alwaysCumulative(); + case "delta": + return AggregationTemporalitySelector.deltaPreferred(); + case "lowmemory": + return AggregationTemporalitySelector.lowMemory(); + default: + throw new ConfigurationException("Unrecognized aggregation temporality: " + temporalityStr); + } + } + + private static DefaultAggregationSelector getDefaultAggregationSelector(ConfigProperties config) { + String defaultHistogramAggregation = + EXPORTER.VCAP_SERVICE.METRICS.DEFAULT_HISTOGRAM_AGGREGATION.getValue(config); + if (defaultHistogramAggregation == null) { + return DefaultAggregationSelector.getDefault() + .with(InstrumentType.HISTOGRAM, Aggregation.defaultAggregation()); + } + if (AggregationUtil.aggregationName(Aggregation.base2ExponentialBucketHistogram()) + .equalsIgnoreCase(defaultHistogramAggregation)) { + return DefaultAggregationSelector.getDefault().with(InstrumentType.HISTOGRAM, + Aggregation.base2ExponentialBucketHistogram()); + } else if (AggregationUtil.aggregationName(explicitBucketHistogram()) + .equalsIgnoreCase(defaultHistogramAggregation)) { + return DefaultAggregationSelector.getDefault() + .with(InstrumentType.HISTOGRAM, Aggregation.explicitBucketHistogram()); + } else { + throw new ConfigurationException( + "Unrecognized default histogram aggregation: " + defaultHistogramAggregation); + } + } + + @Override + public String getName() { + return "vcap-service"; + } + + @Override + public MetricExporter createExporter(ConfigProperties config) { + CloudFoundryServiceInstance serviceInstance = serviceProvider.apply(config); + if (serviceInstance == null) { + LOG.info(MSG_NO_BINDING); + return NoopMetricExporter.getInstance(); + } + CloudFoundryCredentials rawCredentials = serviceInstance.getCredentials(); + if (rawCredentials == null) { + LOG.warning(format(MSG_NO_CREDENTIALS, serviceInstance.getName(), serviceInstance.getLabel())); + return NoopMetricExporter.getInstance(); + } + VcapServiceCredentials credentials = credentialParserProvider.apply(config).apply(rawCredentials); + if (credentials == null || !credentials.validate()) { + LOG.warning(format(MSG_INVALID_CREDENTIALS, serviceInstance.getName(), serviceInstance.getLabel())); + return NoopMetricExporter.getInstance(); + } + + MetricExporter exporter; + switch (EXPORTER.VCAP_SERVICE.METRICS.PROTOCOL.getValue(config)) { + case "http/protobuf": + exporter = createHttpProtobufMetricExporter(config, credentials, serviceInstance); + break; + case "grpc": + exporter = createGrpcMetricExporter(config, credentials, serviceInstance); + break; + default: + LOG.warning(format(MSG_UNSUPPORTED_PROTOCOL, EXPORTER.VCAP_SERVICE.METRICS.PROTOCOL.getValue(config), + serviceInstance.getName(), serviceInstance.getLabel())); + return NoopMetricExporter.getInstance(); + } + + if (exporter instanceof NoopMetricExporter) { + return exporter; + } + return FilteringMetricExporter.wrap(exporter).withConfig(config) + .withIncludedNames(EXPORTER.VCAP_SERVICE.METRICS.INCLUDE_NAMES) + .withExcludedNames(EXPORTER.VCAP_SERVICE.METRICS.EXCLUDE_NAMES).build(); + } + + private MetricExporter createGrpcMetricExporter(ConfigProperties config, VcapServiceCredentials credentials, + CloudFoundryServiceInstance serviceInstance) { + OtlpGrpcMetricExporterBuilder builder = + OtlpGrpcMetricExporter.builder().setEndpoint(credentials.getEndpoint()) + .setCompression(EXPORTER.VCAP_SERVICE.METRICS.COMPRESSION.getValue(config)) + .setRetryPolicy(RetryPolicy.getDefault()) + .setAggregationTemporalitySelector(getAggregationTemporalitySelector(config)) + .setDefaultAggregationSelector(getDefaultAggregationSelector(config)); + addTimeout(builder::setTimeout, config); + if (!addTlsConfig(builder::setClientTls, builder::setTrustedCertificates, credentials, + s -> format(s, serviceInstance.getName(), serviceInstance.getLabel()))) { + return NoopMetricExporter.getInstance(); + } + addAuthTokenHeader(builder::addHeader, config, credentials); + return builder.build(); + } + + private MetricExporter createHttpProtobufMetricExporter(ConfigProperties config, VcapServiceCredentials credentials, + CloudFoundryServiceInstance serviceInstance) { + OtlpHttpMetricExporterBuilder builder = + OtlpHttpMetricExporter.builder().setEndpoint(credentials.getEndpoint()) + .setCompression(EXPORTER.VCAP_SERVICE.METRICS.COMPRESSION.getValue(config)) + .setRetryPolicy(RetryPolicy.getDefault()) + .setAggregationTemporalitySelector(getAggregationTemporalitySelector(config)) + .setDefaultAggregationSelector(getDefaultAggregationSelector(config)); + addTimeout(builder::setTimeout, config); + if (!addTlsConfig(builder::setClientTls, builder::setTrustedCertificates, credentials, + s -> format(s, serviceInstance.getName(), serviceInstance.getLabel()))) { + return NoopMetricExporter.getInstance(); + } + addAuthTokenHeader(builder::addHeader, config, credentials); + return builder.build(); + } + + private boolean addTlsConfig(BiConsumer clientTls, Consumer serverCert, + VcapServiceCredentials credentials, Function messageFormatter) { + if (credentials.getClientKey() != null && credentials.getClientCert() != null) { + clientTls.accept(credentials.getClientKey(), credentials.getClientCert()); + if (credentials.getServerCert() != null) { + serverCert.accept(credentials.getServerCert()); + } else { + LOG.info(messageFormatter.apply(MSG_MISSING_SERVER_CERT)); + String serverCertificate = serverCertificateDownloader.download(credentials.getEndpoint()); + if (serverCertificate != null) { + serverCert.accept(serverCertificate.getBytes(StandardCharsets.UTF_8)); + } else { + LOG.warning(messageFormatter.apply(MSG_FAIL_SERVER_CERT_DOWNLOAD)); + return false; + } + } + } + return true; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProvider.java b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProvider.java new file mode 100644 index 00000000..564c28d3 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProvider.java @@ -0,0 +1,161 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.VcapServiceProvider; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.EXPORTER; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.RUNTIME; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporterBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider; +import io.opentelemetry.sdk.common.export.RetryPolicy; +import io.opentelemetry.sdk.trace.export.SpanExporter; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.logging.Logger; + +import static java.lang.String.format; + +public class VcapServiceSpanExporterProvider implements ConfigurableSpanExporterProvider { + + private static final Logger LOG = Logger.getLogger(VcapServiceSpanExporterProvider.class.getName()); + private static final String MSK_SKIP = " Skipping span exporter registration."; + private static final String MSG_UNSUPPORTED_PROTOCOL = + "Unsupported protocol \"%s\" for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_NO_BINDING = + "No generic service binding found matching the configured criteria." + MSK_SKIP; + private static final String MSG_NO_CREDENTIALS = "No credentials found for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_INVALID_CREDENTIALS = + "Invalid credentials found for service binding \"%s\" (%s)." + MSK_SKIP; + private static final String MSG_MISSING_SERVER_CERT = + "No server certificate provided for service binding \"%s\" (%s). Attempting to download the server certificate from the endpoint."; + private static final String MSG_FAIL_SERVER_CERT_DOWNLOAD = + "Failed to download server certificate for service binding \"%s\" (%s)." + MSK_SKIP; + + private final Function serviceProvider; + private final Function> + credentialParserProvider; + private final ServerCertificateDownloader serverCertificateDownloader; + + public VcapServiceSpanExporterProvider() { + this(config -> new VcapServiceProvider(config).get(), + config -> VcapServiceCredentials.parser(config, RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_TRACES_ENDPOINT), + new ServerCertificateDownloader()); + } + + VcapServiceSpanExporterProvider(Function serviceProvider, + Function> credentialParserProvider, + ServerCertificateDownloader serverCertificateDownloader) { + this.serviceProvider = serviceProvider; + this.credentialParserProvider = credentialParserProvider; + this.serverCertificateDownloader = serverCertificateDownloader; + } + + private static void addTimeout(Consumer setter, ConfigProperties config) { + Duration timeout = EXPORTER.VCAP_SERVICE.TRACES.TIMEOUT.getValue(config); + if (timeout != null) { + setter.accept(timeout); + } + } + + private static void addAuthTokenHeader(BiConsumer setter, ConfigProperties config, + VcapServiceCredentials credentials) { + if (credentials.getAuthToken() != null) { + String headerName = RUNTIME.CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.AUTH_HEADER_NAME.getValue(config); + setter.accept(headerName, credentials.getAuthToken()); + } + } + + @Override + public String getName() { + return "vcap-service"; + } + + @Override + public SpanExporter createExporter(ConfigProperties config) { + CloudFoundryServiceInstance serviceInstance = serviceProvider.apply(config); + if (serviceInstance == null) { + LOG.info(MSG_NO_BINDING); + return NoopSpanExporter.getInstance(); + } + CloudFoundryCredentials rawCredentials = serviceInstance.getCredentials(); + if (rawCredentials == null) { + LOG.warning(format(MSG_NO_CREDENTIALS, serviceInstance.getName(), serviceInstance.getLabel())); + return NoopSpanExporter.getInstance(); + } + VcapServiceCredentials credentials = credentialParserProvider.apply(config).apply(rawCredentials); + if (credentials == null || !credentials.validate()) { + LOG.warning(format(MSG_INVALID_CREDENTIALS, serviceInstance.getName(), serviceInstance.getLabel())); + return NoopSpanExporter.getInstance(); + } + + switch (EXPORTER.VCAP_SERVICE.TRACES.PROTOCOL.getValue(config)) { + case "http/protobuf": + return createHttpProtobufSpanExporter(config, credentials, serviceInstance); + case "grpc": + return createGrpcSpanExporter(config, credentials, serviceInstance); + default: + LOG.warning(format(MSG_UNSUPPORTED_PROTOCOL, EXPORTER.VCAP_SERVICE.TRACES.PROTOCOL.getValue(config), + serviceInstance.getName(), serviceInstance.getLabel())); + } + return NoopSpanExporter.getInstance(); + } + + private SpanExporter createGrpcSpanExporter(ConfigProperties config, VcapServiceCredentials credentials, + CloudFoundryServiceInstance serviceInstance) { + OtlpGrpcSpanExporterBuilder builder = + OtlpGrpcSpanExporter.builder().setEndpoint(credentials.getEndpoint()) + .setCompression(EXPORTER.VCAP_SERVICE.TRACES.COMPRESSION.getValue(config)) + .setRetryPolicy(RetryPolicy.getDefault()); + addTimeout(builder::setTimeout, config); + if (!addTlsConfig(builder::setClientTls, builder::setTrustedCertificates, credentials, + s -> format(s, serviceInstance.getName(), serviceInstance.getLabel()))) { + return NoopSpanExporter.getInstance(); + } + addAuthTokenHeader(builder::addHeader, config, credentials); + return builder.build(); + } + + private SpanExporter createHttpProtobufSpanExporter(ConfigProperties config, VcapServiceCredentials credentials, + CloudFoundryServiceInstance serviceInstance) { + OtlpHttpSpanExporterBuilder builder = + OtlpHttpSpanExporter.builder().setEndpoint(credentials.getEndpoint()) + .setCompression(EXPORTER.VCAP_SERVICE.TRACES.COMPRESSION.getValue(config)) + .setRetryPolicy(RetryPolicy.getDefault()); + addTimeout(builder::setTimeout, config); + if (!addTlsConfig(builder::setClientTls, builder::setTrustedCertificates, credentials, + s -> format(s, serviceInstance.getName(), serviceInstance.getLabel()))) { + return NoopSpanExporter.getInstance(); + } + addAuthTokenHeader(builder::addHeader, config, credentials); + return builder.build(); + } + + private boolean addTlsConfig(BiConsumer clientTls, Consumer serverCert, + VcapServiceCredentials credentials, Function messageFormatter) { + if (credentials.getClientKey() != null && credentials.getClientCert() != null) { + clientTls.accept(credentials.getClientKey(), credentials.getClientCert()); + if (credentials.getServerCert() != null) { + serverCert.accept(credentials.getServerCert()); + } else { + LOG.info(messageFormatter.apply(MSG_MISSING_SERVER_CERT)); + String serverCertificate = serverCertificateDownloader.download(credentials.getEndpoint()); + if (serverCertificate != null) { + serverCert.accept(serverCertificate.getBytes(StandardCharsets.UTF_8)); + } else { + LOG.warning(messageFormatter.apply(MSG_FAIL_SERVER_CERT_DOWNLOAD)); + return false; + } + } + } + return true; + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider b/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider index 811c203e..7d051562 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider @@ -1 +1,2 @@ -com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.CloudLoggingLogsExporterProvider \ No newline at end of file +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.CloudLoggingLogsExporterProvider +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.VcapServiceLogsExporterProvider diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider b/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider index 82d65fa4..164c03d5 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider @@ -1,2 +1,3 @@ com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.CloudLoggingMetricsExporterProvider -com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.DynatraceMetricsExporterProvider \ No newline at end of file +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.DynatraceMetricsExporterProvider +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.VcapServiceMetricsExporterProvider diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider b/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider index 68a755a1..b0f11f07 100644 --- a/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider @@ -1 +1,2 @@ -com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.CloudLoggingSpanExporterProvider \ No newline at end of file +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.CloudLoggingSpanExporterProvider +com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.VcapServiceSpanExporterProvider diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProviderTest.java new file mode 100644 index 00000000..f85e5c0c --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/binding/VcapServiceProviderTest.java @@ -0,0 +1,93 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding; + +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class VcapServiceProviderTest { + + @Mock + private CloudFoundryServicesAdapter adapter; + + @Mock + private CloudFoundryServiceInstance mockService; + + @BeforeEach + void setUp() { + when(adapter.stream(anyList(), anyList(), any())).thenReturn(Stream.of(mockService)); + } + + @Test + void noFiltersWithDefaultConfig() { + DefaultConfigProperties config = DefaultConfigProperties.createFromMap(Collections.emptyMap()); + VcapServiceProvider provider = new VcapServiceProvider(config, adapter); + + assertThat(provider.get()).isEqualTo(mockService); + verify(adapter).stream(Collections.emptyList(), Collections.emptyList(), null); + } + + @Test + void customLabel() { + DefaultConfigProperties config = + DefaultConfigProperties.createFromMap(Map.of("sap.vcap-service.cf.binding.label.value", "my-label")); + VcapServiceProvider provider = new VcapServiceProvider(config, adapter); + + assertThat(provider.get()).isEqualTo(mockService); + verify(adapter).stream(List.of("my-label"), Collections.emptyList(), null); + } + + @Test + void customTag() { + DefaultConfigProperties config = + DefaultConfigProperties.createFromMap(Map.of("sap.vcap-service.cf.binding.tag.value", "my-tag")); + VcapServiceProvider provider = new VcapServiceProvider(config, adapter); + + assertThat(provider.get()).isEqualTo(mockService); + verify(adapter).stream(Collections.emptyList(), List.of("my-tag"), null); + } + + @Test + void customName() { + DefaultConfigProperties config = + DefaultConfigProperties.createFromMap(Map.of("sap.vcap-service.cf.binding.name", "my-service")); + VcapServiceProvider provider = new VcapServiceProvider(config, adapter); + + assertThat(provider.get()).isEqualTo(mockService); + verify(adapter).stream(Collections.emptyList(), Collections.emptyList(), "my-service"); + } + + @Test + void allFiltersConfigured() { + DefaultConfigProperties config = DefaultConfigProperties.createFromMap( + Map.of("sap.vcap-service.cf.binding.label.value", "my-label", "sap.vcap-service.cf.binding.tag.value", + "my-tag", "sap.vcap-service.cf.binding.name", "my-service")); + VcapServiceProvider provider = new VcapServiceProvider(config, adapter); + + assertThat(provider.get()).isEqualTo(mockService); + verify(adapter).stream(List.of("my-label"), List.of("my-tag"), "my-service"); + } + + @Test + void returnsNullWhenNoServiceFound() { + when(adapter.stream(anyList(), anyList(), any())).thenReturn(Stream.empty()); + DefaultConfigProperties config = DefaultConfigProperties.createFromMap(Collections.emptyMap()); + VcapServiceProvider provider = new VcapServiceProvider(config, adapter); + + assertThat(provider.get()).isNull(); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentialsTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentialsTest.java new file mode 100644 index 00000000..7fc0dc67 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceCredentialsTest.java @@ -0,0 +1,102 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Map; + +import static com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +class VcapServiceCredentialsTest { + + private static final ConfigProperties TEST_CONFIG = DefaultConfigProperties.createFromMap( + Map.of(CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_ENDPOINT.getKey(), "url", + CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_CLIENT_KEY.getKey(), "client-key", + CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_CLIENT_CERT.getKey(), "client-cert", + CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.OTLP_SERVER_CERT.getKey(), "server-ca", + CLOUD_FOUNDRY.SERVICE.VCAP_SERVICE.CREDENTIALS.AUTH_TOKEN.getKey(), "auth-token")); + + @Test + void returnsFieldsFromBuilder() { + CloudFoundryCredentials credentials = + CloudFoundryCredentials.builder().add("url", "test-endpoint-url").add("client-key", "test-client-key") + .add("client-cert", "test-client-cert").add("server-ca", "test-server-ca") + .add("auth-token", "test-auth-token").build(); + + VcapServiceCredentials vcapServiceCredentials = VcapServiceCredentials.parser(TEST_CONFIG).apply(credentials); + + assertThat(vcapServiceCredentials.getEndpoint()).isEqualTo("test-endpoint-url"); + assertThat(vcapServiceCredentials.getClientKey()).isEqualTo("test-client-key".getBytes(UTF_8)); + assertThat(vcapServiceCredentials.getClientCert()).isEqualTo("test-client-cert".getBytes(UTF_8)); + assertThat(vcapServiceCredentials.getServerCert()).isEqualTo("test-server-ca".getBytes(UTF_8)); + assertThat(vcapServiceCredentials.getAuthToken()).isEqualTo("test-auth-token"); + } + + @Test + void nullWithoutEndpointCredentialName() { + DefaultConfigProperties config = DefaultConfigProperties.createFromMap(Collections.emptyMap()); + CloudFoundryCredentials ignored = CloudFoundryCredentials.builder().build(); + + VcapServiceCredentials vcapServiceCredentials = VcapServiceCredentials.parser(config).apply(ignored); + + assertThat(vcapServiceCredentials).isNull(); + } + + @Test + void invalidWithoutEndpointUrl() { + CloudFoundryCredentials empty = CloudFoundryCredentials.builder().build(); + + VcapServiceCredentials vcapServiceCredentials = VcapServiceCredentials.parser(TEST_CONFIG).apply(empty); + + assertThat(vcapServiceCredentials.getEndpoint()).isNull(); + assertThat(vcapServiceCredentials.validate()).isFalse(); + } + + @Test + void validWithEndpointAndAuthToken() { + CloudFoundryCredentials credentials = + CloudFoundryCredentials.builder().add("url", "test-endpoint-url").add("auth-token", "test-auth-token") + .build(); + + VcapServiceCredentials vcapServiceCredentials = VcapServiceCredentials.parser(TEST_CONFIG).apply(credentials); + + assertThat(vcapServiceCredentials.validate()).isTrue(); + } + + @Test + void validWithEndpointAndTlsSecrets() { + CloudFoundryCredentials credentials = + CloudFoundryCredentials.builder().add("url", "test-endpoint-url").add("client-key", "test-client-key") + .add("client-cert", "test-client-cert").add("server-ca", "test-server-ca") + .build(); + + VcapServiceCredentials vcapServiceCredentials = VcapServiceCredentials.parser(TEST_CONFIG).apply(credentials); + + assertThat(vcapServiceCredentials.validate()).isTrue(); + } + + @Test + void validWithEndpointAndTlsSecretsWithoutServerCa() { + CloudFoundryCredentials credentials = + CloudFoundryCredentials.builder().add("url", "test-endpoint-url").add("client-key", "test-client-key") + .add("client-cert", "test-client-cert").build(); + + VcapServiceCredentials vcapServiceCredentials = VcapServiceCredentials.parser(TEST_CONFIG).apply(credentials); + + assertThat(vcapServiceCredentials.validate()).isTrue(); + } + + @Test + void invalidWithEndpointAndNoAuthTokenOrTlsSecrets() { + CloudFoundryCredentials credentials = CloudFoundryCredentials.builder().add("url", "test-endpoint-url").build(); + + VcapServiceCredentials vcapServiceCredentials = VcapServiceCredentials.parser(TEST_CONFIG).apply(credentials); + + assertThat(vcapServiceCredentials.validate()).isFalse(); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProviderTest.java new file mode 100644 index 00000000..f465fd90 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceLogsExporterProviderTest.java @@ -0,0 +1,226 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.util.ServiceLoader; +import java.util.function.Function; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class VcapServiceLogsExporterProviderTest { + + @Mock + private Function serviceProvider; + + @Mock + private Function> + credentialParserProvider; + + @Mock + private Function credentialParser; + + @Mock + private ServerCertificateDownloader serverCertificateDownloader; + + @Mock(strictness = LENIENT) + private ConfigProperties config; + + private VcapServiceLogsExporterProvider exporterProvider; + + @BeforeEach + void setUp() { + when(config.getString(any(), any())).thenAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return invocation.getArguments()[1]; + } + }); + exporterProvider = new VcapServiceLogsExporterProvider(serviceProvider, credentialParserProvider, + serverCertificateDownloader); + } + + @Test + void canLoadViaSPI() { + ServiceLoader loader = + ServiceLoader.load(ConfigurableLogRecordExporterProvider.class); + Stream providers = StreamSupport.stream(loader.spliterator(), false); + assertThat(providers).describedAs(VcapServiceLogsExporterProvider.class.getName() + " not loaded via SPI") + .anySatisfy(p -> assertThat(p).isInstanceOf(VcapServiceLogsExporterProvider.class)); + } + + @Test + void registersNoopExporterWithoutBinding() { + when(serviceProvider.apply(config)).thenReturn(null); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithoutCredentials() { + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label").build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithInvalidCredentials() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials invalidCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(invalidCredentials); + when(invalidCredentials.validate()).thenReturn(false); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithUnsupportedProtocol() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(config.getString("otel.exporter.vcap-service.logs.protocol")).thenReturn("unsupported"); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersHttpProtobufExporterWithTlsCredentials() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpLogRecordExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersGrpcExporterWithTlsCredentials() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(config.getString("otel.exporter.vcap-service.logs.protocol")).thenReturn("grpc"); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpGrpcLogRecordExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersExporterWhenServerCertIsDownloaded() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(null); + when(serverCertificateDownloader.download("https://otlp-example.sap")).thenReturn( + new String(PEMUtil.read("certificate.pem"))); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpLogRecordExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersNoopExporterWhenServerCertDownloadFails() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(null); + when(serverCertificateDownloader.download("https://otlp-example.sap")).thenReturn(null); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersExporterWithAuthToken() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientKey()).thenReturn(null); + when(validCredentials.getAuthToken()).thenReturn("test-auth-token"); + LogRecordExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpLogRecordExporter") + .containsSubsequence("https://otlp-example.sap"); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProviderTest.java new file mode 100644 index 00000000..867f6dc8 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceMetricsExporterProviderTest.java @@ -0,0 +1,292 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; +import io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.util.List; +import java.util.ServiceLoader; +import java.util.function.Function; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class VcapServiceMetricsExporterProviderTest { + + @Mock + private Function serviceProvider; + + @Mock + private Function> + credentialParserProvider; + + @Mock + private Function credentialParser; + + @Mock + private ServerCertificateDownloader serverCertificateDownloader; + + @Mock(strictness = LENIENT) + private ConfigProperties config; + + private VcapServiceMetricsExporterProvider exporterProvider; + + @BeforeEach + void setUp() { + when(config.getString(any(), any())).thenAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return invocation.getArguments()[1]; + } + }); + exporterProvider = new VcapServiceMetricsExporterProvider(serviceProvider, credentialParserProvider, + serverCertificateDownloader); + } + + @Test + void canLoadViaSPI() { + ServiceLoader loader = + ServiceLoader.load(ConfigurableMetricExporterProvider.class); + Stream providers = StreamSupport.stream(loader.spliterator(), false); + assertThat(providers).describedAs(VcapServiceMetricsExporterProvider.class.getName() + " not loaded via SPI") + .anySatisfy(p -> assertThat(p).isInstanceOf(VcapServiceMetricsExporterProvider.class)); + } + + @Test + void hasNameVcapService() { + assertThat(exporterProvider.getName()).isEqualTo("vcap-service"); + } + + @Test + void registersNoopExporterWithoutBinding() { + when(serviceProvider.apply(config)).thenReturn(null); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithoutCredentials() { + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label").build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithInvalidCredentials() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials invalidCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(invalidCredentials); + when(invalidCredentials.validate()).thenReturn(false); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithUnsupportedProtocol() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(config.getString("otel.exporter.vcap-service.metrics.protocol")).thenReturn("unsupported"); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersHttpProtobufExporterWithTlsCredentials() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpMetricExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersGrpcExporterWithTlsCredentials() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(config.getString("otel.exporter.vcap-service.metrics.protocol")).thenReturn("grpc"); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpGrpcMetricExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersExporterWhenServerCertIsDownloaded() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(null); + when(serverCertificateDownloader.download("https://otlp-example.sap")).thenReturn( + new String(PEMUtil.read("certificate.pem"))); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpMetricExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersNoopExporterWhenServerCertDownloadFails() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(null); + when(serverCertificateDownloader.download("https://otlp-example.sap")).thenReturn(null); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersExporterWithAuthToken() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientKey()).thenReturn(null); + when(validCredentials.getAuthToken()).thenReturn("test-auth-token"); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpMetricExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void wrapsExporterWithFilteringWhenIncludeNamesConfigured() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(config.getList("otel.exporter.vcap-service.metrics.include.names")).thenReturn( + List.of("jvm.memory.used", "jvm.cpu*")); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isInstanceOf(FilteringMetricExporter.class); + } + + @Test + void wrapsExporterWithFilteringWhenExcludeNamesConfigured() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(config.getList("otel.exporter.vcap-service.metrics.exclude.names")).thenReturn(List.of("jvm.gc*")); + MetricExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isInstanceOf(FilteringMetricExporter.class); + } + + @Test + void throwsOnUnrecognizedTemporalityPreference() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(config.getString("otel.exporter.vcap-service.metrics.temporality.preference")).thenReturn("unknown"); + assertThatThrownBy(() -> exporterProvider.createExporter(config)).isInstanceOf(ConfigurationException.class) + .hasMessageContaining("unknown"); + } +} diff --git a/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProviderTest.java b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProviderTest.java new file mode 100644 index 00000000..8c921d88 --- /dev/null +++ b/cf-java-logging-support-opentelemetry-agent-extension/src/test/java/com/sap/hcf/cf/logging/opentelemetry/agent/ext/exporter/VcapServiceSpanExporterProviderTest.java @@ -0,0 +1,231 @@ +package com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter; + +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryCredentials; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudFoundryServiceInstance; +import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.ServerCertificateDownloader; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.util.ServiceLoader; +import java.util.function.Function; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class VcapServiceSpanExporterProviderTest { + + @Mock + private Function serviceProvider; + + @Mock + private Function> + credentialParserProvider; + + @Mock + private Function credentialParser; + + @Mock + private ServerCertificateDownloader serverCertificateDownloader; + + @Mock(strictness = LENIENT) + private ConfigProperties config; + + private VcapServiceSpanExporterProvider exporterProvider; + + @BeforeEach + void setUp() { + when(config.getString(any(), any())).thenAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return invocation.getArguments()[1]; + } + }); + exporterProvider = new VcapServiceSpanExporterProvider(serviceProvider, credentialParserProvider, + serverCertificateDownloader); + } + + @Test + void canLoadViaSPI() { + ServiceLoader loader = + ServiceLoader.load(ConfigurableSpanExporterProvider.class); + Stream providers = StreamSupport.stream(loader.spliterator(), false); + assertThat(providers).describedAs(VcapServiceSpanExporterProvider.class.getName() + " not loaded via SPI") + .anySatisfy(p -> assertThat(p).isInstanceOf(VcapServiceSpanExporterProvider.class)); + } + + @Test + void hasNameVcapService() { + assertThat(exporterProvider.getName()).isEqualTo("vcap-service"); + } + + @Test + void registersNoopExporterWithoutBinding() { + when(serviceProvider.apply(config)).thenReturn(null); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithoutCredentials() { + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label").build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithInvalidCredentials() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials invalidCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(invalidCredentials); + when(invalidCredentials.validate()).thenReturn(false); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersNoopExporterWithUnsupportedProtocol() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(config.getString("otel.exporter.vcap-service.traces.protocol")).thenReturn("unsupported"); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersHttpProtobufExporterWithTlsCredentials() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpSpanExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersGrpcExporterWithTlsCredentials() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(config.getString("otel.exporter.vcap-service.traces.protocol")).thenReturn("grpc"); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpGrpcSpanExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersExporterWhenServerCertIsDownloaded() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(null); + when(serverCertificateDownloader.download("https://otlp-example.sap")).thenReturn( + new String(PEMUtil.read("certificate.pem"))); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpSpanExporter") + .containsSubsequence("https://otlp-example.sap"); + } + + @Test + void registersNoopExporterWhenServerCertDownloadFails() throws IOException { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientCert()).thenReturn(PEMUtil.read("certificate.pem")); + when(validCredentials.getClientKey()).thenReturn(PEMUtil.read("private.pem")); + when(validCredentials.getServerCert()).thenReturn(null); + when(serverCertificateDownloader.download("https://otlp-example.sap")).thenReturn(null); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("Noop"); + } + + @Test + void registersExporterWithAuthToken() { + CloudFoundryCredentials rawCredentials = CloudFoundryCredentials.builder().build(); + CloudFoundryServiceInstance serviceInstance = + CloudFoundryServiceInstance.builder().name("test-service").label("test-label") + .credentials(rawCredentials).build(); + when(serviceProvider.apply(config)).thenReturn(serviceInstance); + when(credentialParserProvider.apply(config)).thenReturn(credentialParser); + VcapServiceCredentials validCredentials = mock(VcapServiceCredentials.class); + when(credentialParser.apply(any())).thenReturn(validCredentials); + when(validCredentials.validate()).thenReturn(true); + when(validCredentials.getEndpoint()).thenReturn("https://otlp-example.sap"); + when(validCredentials.getClientKey()).thenReturn(null); + when(validCredentials.getAuthToken()).thenReturn("test-auth-token"); + SpanExporter exporter = exporterProvider.createExporter(config); + assertThat(exporter).isNotNull(); + assertThat(exporter.toString()).containsSubsequence("OtlpHttpSpanExporter") + .containsSubsequence("https://otlp-example.sap"); + } +}