diff --git a/.github/component_owners.yml b/.github/component_owners.yml index a7489b9de..d6647e332 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -22,6 +22,8 @@ components: baggage-procesor: - mikegoldsmith - zeitlinger + cloudfoundry-resources: + - KarstenSchnitter compressors: - jack-berg consistent-sampling: diff --git a/cloudfoundry-resources/README.md b/cloudfoundry-resources/README.md new file mode 100644 index 000000000..355f9ce6e --- /dev/null +++ b/cloudfoundry-resources/README.md @@ -0,0 +1,27 @@ +# OpenTelemetry CloudFoundry Resource Support + +This module contains CloudFoundry resource detectors for OpenTelemetry. + +The module detects environment variable `VCAP_APPLICATION`, which is present for applications deployed in CloudFoundry. +This variable contains a JSON structure, which is parsed to fill the following attributes. + +| Resource attribute | `VCAP_APPLICATION` field | +|------------------------------|--------------------------| +| cloudfoundry.app.id | application_id | +| cloudfoundry.app.name | application_name | +| cloudfoundry.app.instance.id | instance_index | +| cloudfoundry.org.id | organization_id | +| cloudfoundry.org.name | organization_name | +| cloudfoundry.process.id | process_id | +| cloudfoundry.process.type | process_type | +| cloudfoundry.space.id | space_id | +| cloudfoundry.space.name | space_name | + +The resource attributes follow the [CloudFoundry semantic convention.](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/attributes-registry/cloudfoundry.md). +A description of `VCAP_APPLICATION` is available in the [CloudFoundry documentation](https://docs.cloudfoundry.org/devguide/deploy-apps/environment-variable.html#VCAP-APPLICATION). + +## Component owners + +- [Karsten Schnitter](https://github.com/KarstenSchnitter), SAP + +Learn more about component owners in [component_owners.yml](../.github/component_owners.yml). diff --git a/cloudfoundry-resources/build.gradle.kts b/cloudfoundry-resources/build.gradle.kts new file mode 100644 index 000000000..e768f7389 --- /dev/null +++ b/cloudfoundry-resources/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("otel.java-conventions") + + id("otel.publish-conventions") +} + +description = "OpenTelemetry CloudFoundry Resources" +otelJava.moduleName.set("io.opentelemetry.contrib.cloudfoundry.resources") + +dependencies { + api("io.opentelemetry:opentelemetry-api") + api("io.opentelemetry:opentelemetry-sdk") + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") + + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") + + implementation("com.fasterxml.jackson.core:jackson-core") + implementation("io.opentelemetry.semconv:opentelemetry-semconv") + testImplementation("io.opentelemetry.semconv:opentelemetry-semconv-incubating") + + testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") +} diff --git a/cloudfoundry-resources/src/main/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResource.java b/cloudfoundry-resources/src/main/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResource.java new file mode 100644 index 000000000..7d6313928 --- /dev/null +++ b/cloudfoundry-resources/src/main/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResource.java @@ -0,0 +1,106 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.cloudfoundry.resources; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.semconv.SchemaUrls; +import java.io.IOException; +import java.util.function.Function; +import java.util.logging.Logger; + +public final class CloudFoundryResource { + + private static final String ENV_VCAP_APPLICATION = "VCAP_APPLICATION"; + + // copied from CloudfoundryIncubatingAttributes + private static final AttributeKey CLOUDFOUNDRY_APP_ID = + AttributeKey.stringKey("cloudfoundry.app.id"); + private static final AttributeKey CLOUDFOUNDRY_APP_INSTANCE_ID = + AttributeKey.stringKey("cloudfoundry.app.instance.id"); + private static final AttributeKey CLOUDFOUNDRY_APP_NAME = + AttributeKey.stringKey("cloudfoundry.app.name"); + private static final AttributeKey CLOUDFOUNDRY_ORG_ID = + AttributeKey.stringKey("cloudfoundry.org.id"); + private static final AttributeKey CLOUDFOUNDRY_ORG_NAME = + AttributeKey.stringKey("cloudfoundry.org.name"); + private static final AttributeKey CLOUDFOUNDRY_PROCESS_ID = + AttributeKey.stringKey("cloudfoundry.process.id"); + private static final AttributeKey CLOUDFOUNDRY_PROCESS_TYPE = + AttributeKey.stringKey("cloudfoundry.process.type"); + private static final AttributeKey CLOUDFOUNDRY_SPACE_ID = + AttributeKey.stringKey("cloudfoundry.space.id"); + private static final AttributeKey CLOUDFOUNDRY_SPACE_NAME = + AttributeKey.stringKey("cloudfoundry.space.name"); + private static final Logger LOG = Logger.getLogger(CloudFoundryResource.class.getName()); + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + private static final Resource INSTANCE = buildResource(System::getenv); + + private CloudFoundryResource() {} + + public static Resource get() { + return INSTANCE; + } + + static Resource buildResource(Function getenv) { + String vcapAppRaw = getenv.apply(ENV_VCAP_APPLICATION); + // If there is no VCAP_APPLICATION in the environment, we are likely not running in CloudFoundry + if (vcapAppRaw == null || vcapAppRaw.isEmpty()) { + return Resource.empty(); + } + + AttributesBuilder builder = Attributes.builder(); + try (JsonParser parser = JSON_FACTORY.createParser(vcapAppRaw)) { + parser.nextToken(); + while (parser.nextToken() != JsonToken.END_OBJECT) { + String name = parser.currentName(); + parser.nextToken(); + String value = parser.getValueAsString(); + switch (name) { + case "application_id": + builder.put(CLOUDFOUNDRY_APP_ID, value); + break; + case "application_name": + builder.put(CLOUDFOUNDRY_APP_NAME, value); + break; + case "instance_index": + builder.put(CLOUDFOUNDRY_APP_INSTANCE_ID, value); + break; + case "organization_id": + builder.put(CLOUDFOUNDRY_ORG_ID, value); + break; + case "organization_name": + builder.put(CLOUDFOUNDRY_ORG_NAME, value); + break; + case "process_id": + builder.put(CLOUDFOUNDRY_PROCESS_ID, value); + break; + case "process_type": + builder.put(CLOUDFOUNDRY_PROCESS_TYPE, value); + break; + case "space_id": + builder.put(CLOUDFOUNDRY_SPACE_ID, value); + break; + case "space_name": + builder.put(CLOUDFOUNDRY_SPACE_NAME, value); + break; + default: + parser.skipChildren(); + break; + } + } + } catch (IOException e) { + LOG.warning("Cannot parse contents of environment variable VCAP_APPLICATION. Invalid JSON"); + } + + return Resource.create(builder.build(), SchemaUrls.V1_24_0); + } +} diff --git a/cloudfoundry-resources/src/main/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResourceProvider.java b/cloudfoundry-resources/src/main/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResourceProvider.java new file mode 100644 index 000000000..e3f3e3c64 --- /dev/null +++ b/cloudfoundry-resources/src/main/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResourceProvider.java @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.cloudfoundry.resources; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider; +import io.opentelemetry.sdk.resources.Resource; + +public class CloudFoundryResourceProvider implements ResourceProvider { + + @Override + public Resource createResource(ConfigProperties configProperties) { + return CloudFoundryResource.get(); + } +} diff --git a/cloudfoundry-resources/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider b/cloudfoundry-resources/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider new file mode 100644 index 000000000..0b0f252ca --- /dev/null +++ b/cloudfoundry-resources/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider @@ -0,0 +1 @@ +io.opentelemetry.contrib.cloudfoundry.resources.CloudFoundryResourceProvider diff --git a/cloudfoundry-resources/src/test/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResourceTest.java b/cloudfoundry-resources/src/test/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResourceTest.java new file mode 100644 index 000000000..1c533cd8a --- /dev/null +++ b/cloudfoundry-resources/src/test/java/io/opentelemetry/contrib/cloudfoundry/resources/CloudFoundryResourceTest.java @@ -0,0 +1,89 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.contrib.cloudfoundry.resources; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.semconv.SchemaUrls; +import io.opentelemetry.semconv.incubating.CloudfoundryIncubatingAttributes; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class CloudFoundryResourceTest { + + private static Map createVcapApplicationEnv(String value) { + Map environment = new HashMap<>(); + environment.put("VCAP_APPLICATION", value); + return environment; + } + + private static String loadVcapApplicationSample(String filename) { + try (InputStream is = + CloudFoundryResourceTest.class.getClassLoader().getResourceAsStream(filename)) { + if (is != null) { + return new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)) + .lines() + .collect(Collectors.joining()); + } + Assertions.fail("Cannot load resource " + filename); + } catch (IOException e) { + Assertions.fail("Error reading " + filename); + } + return ""; + } + + @Test + void noVcapApplication() { + Map env = Collections.emptyMap(); + Resource resource = CloudFoundryResource.buildResource(env::get); + assertThat(resource).isEqualTo(Resource.empty()); + } + + @Test + void emptyVcapApplication() { + Map env = createVcapApplicationEnv(""); + Resource resource = CloudFoundryResource.buildResource(env::get); + assertThat(resource).isEqualTo(Resource.empty()); + } + + @Test + void fullVcapApplication() { + String json = loadVcapApplicationSample("vcap_application.json"); + Map env = createVcapApplicationEnv(json); + + Resource resource = CloudFoundryResource.buildResource(env::get); + + assertThat(resource.getSchemaUrl()).isEqualTo(SchemaUrls.V1_24_0); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_APP_ID)) + .isEqualTo("0193a038-e615-7e5e-92ca-f4bcd7ba0a25"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_APP_INSTANCE_ID)) + .isEqualTo("1"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_APP_NAME)) + .isEqualTo("cf-app-name"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_ORG_ID)) + .isEqualTo("0193a375-8d8e-7e0c-a832-01ce9ded40dc"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_ORG_NAME)) + .isEqualTo("cf-org-name"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_PROCESS_ID)) + .isEqualTo("0193a4e3-8fd3-71b9-9fe3-5640c53bf1e2"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_PROCESS_TYPE)) + .isEqualTo("web"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_SPACE_ID)) + .isEqualTo("0193a7e7-da17-7ea4-8940-b1e07b401b16"); + assertThat(resource.getAttribute(CloudfoundryIncubatingAttributes.CLOUDFOUNDRY_SPACE_NAME)) + .isEqualTo("cf-space-name"); + } +} diff --git a/cloudfoundry-resources/src/test/resources/vcap_application.json b/cloudfoundry-resources/src/test/resources/vcap_application.json new file mode 100644 index 000000000..e6eb34eaa --- /dev/null +++ b/cloudfoundry-resources/src/test/resources/vcap_application.json @@ -0,0 +1,19 @@ +{ + "application_id": "0193a038-e615-7e5e-92ca-f4bcd7ba0a25", + "application_name": "cf-app-name", + "application_uris": [ + "testapp.example.com" + ], + "cf_api": "https://api.cf.example.com", + "limits": { + "fds": 256 + }, + "instance_index": 1, + "organization_id": "0193a375-8d8e-7e0c-a832-01ce9ded40dc", + "organization_name": "cf-org-name", + "process_id": "0193a4e3-8fd3-71b9-9fe3-5640c53bf1e2", + "process_type": "web", + "space_id": "0193a7e7-da17-7ea4-8940-b1e07b401b16", + "space_name": "cf-space-name", + "users": null +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 94edc1103..969944ac7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,7 @@ include(":aws-xray") include(":aws-xray-propagator") include(":baggage-processor") include(":compressors:compressor-zstd") +include(":cloudfoundry-resources") include(":consistent-sampling") include(":dependencyManagement") include(":disk-buffering")