Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
51a142d
docs: add Generic OTel Collector + BTP Certificate Service mTLS desig…
DimitarKiryakovSAP Aug 21, 2026
8be179c
feat(otel-ext): add OtelCollector and CertificateService config prope…
DimitarKiryakovSAP Aug 21, 2026
a7bb36b
refactor(otel-ext): remove BTP Certificate Service integration — mTLS…
DimitarKiryakovSAP Aug 21, 2026
b95b108
feat(otel-ext): add findByName() to CloudFoundryServicesAdapter
DimitarKiryakovSAP Aug 21, 2026
46b2d93
feat(otel-ext): add OtelCollectorServiceProvider
DimitarKiryakovSAP Aug 21, 2026
72000ec
feat(otel-ext): add OtelCollectorBindingPropertiesSupplier reading mT…
DimitarKiryakovSAP Aug 21, 2026
e6c9d25
test(otel-ext): add exception-path test for OtelCollectorBindingPrope…
DimitarKiryakovSAP Aug 21, 2026
d56c3f1
feat(otel-ext): wire OtelCollectorBindingPropertiesSupplier into cust…
DimitarKiryakovSAP Aug 21, 2026
85a4c7e
docs(otel-ext): replace stale BTP Certificate Service docs with actua…
DimitarKiryakovSAP Aug 21, 2026
cf92fee
chore: remove internal planning docs
DimitarKiryakovSAP Aug 21, 2026
05f36f7
refactor(otel-ext): remove out-of-scope UAA credential merging
DimitarKiryakovSAP Aug 21, 2026
a0ab920
feat(otel-ext): allow mTLS and Bearer token to be used simultaneously
DimitarKiryakovSAP Aug 21, 2026
67eae45
refactor(otel-ext): restore original parseServiceCredentials signature
DimitarKiryakovSAP Aug 21, 2026
e7adff6
refactor: extract MtlsClientCertHelper and make OtelCollector mTLS ma…
DimitarKiryakovSAP Aug 21, 2026
eee5416
refactor(caas): inline getString calls in mTLS guard, remove stale lo…
DimitarKiryakovSAP Aug 21, 2026
bacb4c4
refactor(mtls): pass clientCert/clientKey strings into helper instead…
DimitarKiryakovSAP Aug 21, 2026
cf4a308
refactor(mtls): add serverCert to helper, unify all TLS PEM writes in…
DimitarKiryakovSAP Aug 21, 2026
b9348a6
refactor(mtls): introduce TlsCertificates parameter object to reduce …
DimitarKiryakovSAP Aug 21, 2026
a7ac3f3
Add Javadoc to public methods and remove task-tracking comments
DimitarKiryakovSAP Aug 21, 2026
6859cda
Add missing test coverage and fix assertion typo found in code review
DimitarKiryakovSAP Aug 21, 2026
d933b8d
fix(otel-ext): fix MtlsClientCertHelper partial-write regression and …
DimitarKiryakovSAP Aug 21, 2026
cc977fe
revert: remove javadoc from pre-existing classes not in scope of this PR
DimitarKiryakovSAP Aug 21, 2026
cdb4bea
docs(otel-ext): add Javadoc to findByName and MtlsClientCertHelper
DimitarKiryakovSAP Aug 24, 2026
3a33264
feat(otel-ext): make tls.ca.crt mandatory for OTel Collector binding
DimitarKiryakovSAP Aug 24, 2026
1c05018
refactor(otel-ext): serverCert is always required in applyTlsProperties
DimitarKiryakovSAP Aug 24, 2026
9faf4b9
refactor(otel-ext): group File operations together in applyTlsProperties
DimitarKiryakovSAP Aug 24, 2026
4045310
Revert "refactor(otel-ext): group File operations together in applyTl…
DimitarKiryakovSAP Aug 24, 2026
9de68cf
refactor(otel-ext): remove isBlank checks on clientCert/clientKey in …
DimitarKiryakovSAP Aug 24, 2026
3366181
refactor(otel-ext): remove Javadoc from CaasBindingPropertiesSupplier
DimitarKiryakovSAP Aug 24, 2026
9c17dd5
refactor(otel-ext): remove isBlank checks on clientCert/clientKey in …
DimitarKiryakovSAP Aug 24, 2026
1b06714
Revert "refactor(otel-ext): remove isBlank checks on clientCert/clien…
DimitarKiryakovSAP Aug 24, 2026
f3ac15b
Revert "refactor(otel-ext): remove isBlank checks on clientCert/clien…
DimitarKiryakovSAP Aug 24, 2026
0c11ab8
Remove isBlank checks on clientCert/clientKey in CaasBindingPropertie…
DimitarKiryakovSAP Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 77 additions & 16 deletions cf-java-logging-support-opentelemetry-agent-extension/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CaasBindingPropertiesSupplier;
import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.CloudLoggingBindingPropertiesSupplier;
import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.DefaultOtelBackendPropertiesSupplier;
import com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.OtelCollectorBindingPropertiesSupplier;
import com.sap.hcf.cf.logging.opentelemetry.agent.ext.exporter.SanitizeSpanExporterCustomizer;
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer;
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider;
Expand All @@ -18,6 +19,7 @@ public class CloudLoggingConfigurationCustomizerProvider implements AutoConfigur

private static DefaultOtelBackendPropertiesSupplier getDefaultOtelBackendPropertiesSupplier() {
return builder() //
.add(new OtelCollectorBindingPropertiesSupplier()) // no-op unless sap.otel.collector.cf.binding.name is set
.add(new CaasBindingPropertiesSupplier()) // this has priority
.add(new CloudLoggingBindingPropertiesSupplier()) // look for Cloud Logging as fallback and backward compatibility
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import io.opentelemetry.common.ComponentLoader;
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;

import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -84,13 +83,11 @@ public Map<String, String> get() {
if (serverCert == null || serverCert.isBlank()) {
return properties;
}
File serverCertFile = pemFileCreator.writeFile("caas-server-cert-", ".crt", serverCert);
File clientCertFile = pemFileCreator.writeFile("caas-client-cert-", ".crt", clientCert);
File clientKeyFile = pemFileCreator.writeFile("caas-client-key-", ".key", clientKey);

properties.put("otel.exporter.otlp.certificate", serverCertFile.getAbsolutePath());
properties.put("otel.exporter.otlp.client.certificate", clientCertFile.getAbsolutePath());
properties.put("otel.exporter.otlp.client.key", clientKeyFile.getAbsolutePath());
MtlsClientCertHelper.applyTlsProperties(
new TlsCertificates(clientCert, clientKey, serverCert),
pemFileCreator,
"caas-client-cert-", "caas-client-key-", "caas-server-cert-",
properties);

} catch (IOException e) {
LOG.warning(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Logger;
Expand Down Expand Up @@ -84,6 +85,48 @@ Stream<CloudFoundryServiceInstance> stream(List<String> serviceLabels, List<Stri
return Stream.empty();
}

/**
* Searches all service instances in {@code VCAP_SERVICES} for one whose {@code name} field matches
* the given instance name exactly.
*
* @param instanceName the service instance name to look for (exact match on the {@code name} field)
* @return the first matching service instance wrapped in an {@link Optional}, or {@link Optional#empty()}
* if {@code instanceName} is {@code null} or blank, if {@code VCAP_SERVICES} is absent or blank, or if no
* instance matches
*/
Optional<CloudFoundryServiceInstance> findByName(String instanceName) {
if (instanceName == null || instanceName.isBlank()) {
return Optional.empty();
}
if (vcapServicesJson == null || vcapServicesJson.isBlank()) {
return Optional.empty();
}
try (JsonParser parser = new JsonFactory().createParser(vcapServicesJson)) {
if (parser.nextToken() != JsonToken.START_OBJECT) {
return Optional.empty();
}
while (parser.nextToken() != JsonToken.END_OBJECT) {
String label = parser.currentName();
parser.nextToken(); // START_ARRAY
if (parser.currentToken() != JsonToken.START_ARRAY) {
parser.skipChildren();
continue;
}
while (parser.nextToken() != JsonToken.END_ARRAY) {
if (parser.currentToken() == JsonToken.START_OBJECT) {
CloudFoundryServiceInstance instance = parseServiceInstance(label, parser);
if (instanceName.equals(instance.getName())) {
return Optional.of(instance);
}
}
}
}
} catch (IOException e) {
LOG.warning("Failed to parse VCAP_SERVICES for findByName: " + e.getMessage());
}
return Optional.empty();
}

private static void parseServiceInstances(JsonParser parser, String label,
Consumer<CloudFoundryServiceInstance> consumer) throws IOException {
if (parser.nextToken() == JsonToken.START_ARRAY) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding;

import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator;

import java.io.File;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* Package-private utility that writes mTLS PEM certificate files to disk and populates the corresponding
* OTLP exporter properties.
* <p>
* It uses an atomic buffering pattern: all TLS properties are collected in a local map first, and only merged
* into the output map after all file writes succeed. As a result, a partial {@link IOException} never leaves
* the caller with a half-configured TLS state.
*/
class MtlsClientCertHelper {

private MtlsClientCertHelper() {}

/**
* Writes the given TLS certificates to temporary PEM files and populates {@code props} with the matching
* OTLP exporter properties.
* <p>
* {@code serverCert} is always written to a temporary PEM file and {@code otel.exporter.otlp.certificate}
* is added. If {@code clientCert} or {@code clientKey} is {@code null} or blank, only the server certificate
* property is merged into {@code props} and {@code false} is returned. Otherwise the client certificate and
* key are also written to temporary PEM files, {@code otel.exporter.otlp.client.certificate} and
* {@code otel.exporter.otlp.client.key} are added, everything is merged into {@code props}, and {@code true}
* is returned.
* <p>
* If any file write fails, an {@link IOException} is thrown and {@code props} is not modified at all
* (atomicity guarantee).
*
* @param certs the server certificate (required), client certificate and client key strings
* @param pemFileCreator writes the temporary PEM files
* @param certFilePrefix temp file name prefix for the client certificate
* @param keyFilePrefix temp file name prefix for the client key
* @param serverCertFilePrefix temp file name prefix for the server certificate
* @param props the output map to populate with OTLP exporter properties
* @return {@code true} if full mTLS (client and server) was configured, {@code false} if only the server
* certificate was set
* @throws IOException if any certificate file cannot be written; in that case {@code props} is left unchanged
*/
static boolean applyTlsProperties(TlsCertificates certs,
PemFileCreator pemFileCreator,
String certFilePrefix, String keyFilePrefix,
String serverCertFilePrefix,
Map<String, String> props) throws IOException {
Map<String, String> tlsProps = new LinkedHashMap<>();
File serverCertFile = pemFileCreator.writeFile(serverCertFilePrefix, ".crt", certs.serverCert);
tlsProps.put("otel.exporter.otlp.certificate", serverCertFile.getAbsolutePath());
if (certs.clientCert == null || certs.clientCert.isBlank()
|| certs.clientKey == null || certs.clientKey.isBlank()) {
props.putAll(tlsProps);
return false;
}
File certFile = pemFileCreator.writeFile(certFilePrefix, ".crt", certs.clientCert);
File keyFile = pemFileCreator.writeFile(keyFilePrefix, ".key", certs.clientKey);
tlsProps.put("otel.exporter.otlp.client.certificate", certFile.getAbsolutePath());
tlsProps.put("otel.exporter.otlp.client.key", keyFile.getAbsolutePath());
props.putAll(tlsProps);
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding;

import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations;
import com.sap.hcf.cf.logging.opentelemetry.agent.ext.tls.PemFileCreator;
import io.opentelemetry.common.ComponentLoader;
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;

import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;

import static java.util.Collections.emptyMap;

public class OtelCollectorBindingPropertiesSupplier implements Supplier<Map<String, String>> {

private static final Logger LOG =
Logger.getLogger(OtelCollectorBindingPropertiesSupplier.class.getName());

private final ConfigProperties config;
private final OtelCollectorServiceProvider collectorProvider;
private final PemFileCreator pemFileCreator;

/**
* Creates a new instance using default service discovery and TLS infrastructure.
*/
public OtelCollectorBindingPropertiesSupplier() {
this(getDefaultConfigProperties(), new OtelCollectorServiceProvider(), new PemFileCreator());
}

OtelCollectorBindingPropertiesSupplier(ConfigProperties config,
OtelCollectorServiceProvider collectorProvider,
PemFileCreator pemFileCreator) {
this.config = config;
this.collectorProvider = collectorProvider;
this.pemFileCreator = pemFileCreator;
}

private static DefaultConfigProperties getDefaultConfigProperties() {
ComponentLoader componentLoader =
ComponentLoader.forClassLoader(DefaultConfigProperties.class.getClassLoader());
return DefaultConfigProperties.create(emptyMap(), componentLoader);
}

/**
* Reads the OTel Collector service binding credentials from VCAP_SERVICES and returns the OpenTelemetry OTLP
* exporter configuration properties. A server CA certificate ({@code tls.ca.crt}) is required: if it is absent or
* blank, the binding is skipped and an empty map is returned. Supports mTLS and optional Bearer token
* authentication. Logs a warning when the client certificate or key is missing.
*
* @return The pre-configured connection properties for the OpenTelemetry SDK, or an empty map when no usable
* OTel Collector binding is found or the required server CA certificate is missing.
*/
@Override
public Map<String, String> get() {
String bindingName =
ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.OTEL_COLLECTOR.BINDING_NAME
.getValue(config);
if (bindingName == null || bindingName.isBlank()) {
return Collections.emptyMap();
}

Optional<CloudFoundryServiceInstance> instanceOpt = collectorProvider.get();
if (!instanceOpt.isPresent()) {
LOG.warning("OTel Collector binding '" + bindingName + "' not found in VCAP_SERVICES");
return Collections.emptyMap();
}

CloudFoundryCredentials creds = instanceOpt.get().getCredentials();
if (creds == null) {
LOG.warning("OTel Collector binding '" + bindingName + "' has no credentials");
return Collections.emptyMap();
}

String url = creds.getString("url");
if (url == null || url.isBlank()) {
url = creds.getString("http-url");
}
if (url == null || url.isBlank()) {
LOG.warning("OTel Collector binding '" + bindingName + "' has no 'url' or 'http-url'");
return Collections.emptyMap();
}

String caCrt = creds.getString("tls.ca.crt");
if (caCrt == null || caCrt.isBlank()) {
LOG.warning("OTel Collector binding '" + bindingName + "' is missing server CA certificate ('tls.ca.crt').");
return Collections.emptyMap();
}

Map<String, String> props = new LinkedHashMap<>();
props.put("otel.exporter.otlp.endpoint", url);
props.put("otel.exporter.otlp.protocol", "http/protobuf");
props.put("otel.exporter.otlp.compression", "gzip");

TlsCertificates certs = new TlsCertificates(
creds.getString("tls.crt"), creds.getString("tls.key"), caCrt);
try {
if (!MtlsClientCertHelper.applyTlsProperties(certs, pemFileCreator,
"otel-collector-client-cert-", "otel-collector-client-key-", "otel-collector-server-ca-",
props)) {
LOG.warning("OTel Collector binding '" + bindingName + "' is missing client certificate or key.");
}
} catch (IOException e) {
LOG.log(Level.WARNING,
"Failed to write TLS files for OTel Collector binding '" + bindingName + "'", e);
}
String token = creds.getString("token");
if (token != null && !token.isBlank()) {
props.put("otel.exporter.otlp.headers", "Authorization=Bearer " + token);
}

return props;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding;

import com.sap.hcf.cf.logging.opentelemetry.agent.ext.config.ExtensionConfigurations;
import io.opentelemetry.common.ComponentLoader;
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;

import java.util.Optional;
import java.util.function.Supplier;

import static java.util.Collections.emptyMap;

class OtelCollectorServiceProvider implements Supplier<Optional<CloudFoundryServiceInstance>> {

private final ConfigProperties config;
private final CloudFoundryServicesAdapter adapter;

OtelCollectorServiceProvider() {
this(getDefaultConfigProperties(), CloudFoundryServicesAdapter.builder().build());
}

OtelCollectorServiceProvider(ConfigProperties config, CloudFoundryServicesAdapter adapter) {
this.config = config;
this.adapter = adapter;
}

@Override
public Optional<CloudFoundryServiceInstance> get() {
String bindingName =
ExtensionConfigurations.RUNTIME.CLOUD_FOUNDRY.SERVICE.OTEL_COLLECTOR.BINDING_NAME
.getValue(config);
if (bindingName == null || bindingName.isBlank()) {
return Optional.empty();
}
return adapter.findByName(bindingName);
}

private static DefaultConfigProperties getDefaultConfigProperties() {
ComponentLoader componentLoader =
ComponentLoader.forClassLoader(DefaultConfigProperties.class.getClassLoader());
return DefaultConfigProperties.create(emptyMap(), componentLoader);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding;

class TlsCertificates {

final String clientCert;
final String clientKey;
final String serverCert;

TlsCertificates(String clientCert, String clientKey, String serverCert) {
this.clientCert = clientCert;
this.clientKey = clientKey;
this.serverCert = serverCert;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ interface DYNATRACE {
DEPRECATED.RUNTIME.CLOUD_FOUNDRY.SERVICE.DYNATRACE.TOKEN_NAME_OTEL).build();
}

interface OTEL_COLLECTOR {
ConfigProperty<String> BINDING_NAME =
stringValued("sap.otel.collector.cf.binding.name").build();
}

}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,12 @@ public void canLoadViaSPI() {
.anySatisfy(p -> assertThat(p).isInstanceOf(AutoConfigurationCustomizerProvider.class));
}

@Test
void otelCollectorSupplierHasNoArgConstructor() throws Exception {
Class<?> supplierClass = Class.forName(
"com.sap.hcf.cf.logging.opentelemetry.agent.ext.binding.OtelCollectorBindingPropertiesSupplier");
assertThat(java.util.Arrays.stream(supplierClass.getDeclaredConstructors()))
.anyMatch(c -> c.getParameterCount() == 0);
}

}
Loading