diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index 1e88df74..c30f98d8 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { bootstrapLibs(project(":bootstrap")) bootstrapLibs(project(":libs:core")) + bootstrapLibs(project(path = ":libs:resource", configuration = "shadow")) bootstrapLibs(project(":libs:config")) bootstrapLibs(project(":libs:sampling")) diff --git a/custom/build.gradle.kts b/custom/build.gradle.kts index 24f205f2..56e6505e 100644 --- a/custom/build.gradle.kts +++ b/custom/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { testImplementation(project(":libs:sampling")) testImplementation(project(":libs:shared")) testImplementation(project(":libs:core")) + testRuntimeOnly(project(path = ":libs:resource", configuration = "shadow")) testImplementation("io.opentelemetry:opentelemetry-api-incubator") testImplementation("io.opentelemetry:opentelemetry-sdk-extension-incubator") diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 41dfe08d..0b0bcc81 100644 --- a/dependencyManagement/build.gradle.kts +++ b/dependencyManagement/build.gradle.kts @@ -37,8 +37,10 @@ dependencies { api("org.junit.jupiter:junit-jupiter-engine:$junit5") api("io.opentelemetry:opentelemetry-api:$otelSdkVersion") + api("io.opentelemetry:opentelemetry-api-incubator:$opentelemetryAlpha") api("io.opentelemetry.javaagent:opentelemetry-javaagent:$otelAgentVersion") api("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure:$otelSdkVersion") + api("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:$otelSdkVersion") api("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:$otelAgentVersion") api("io.opentelemetry.javaagent:opentelemetry-javaagent-tooling:$opentelemetryJavaagentAlpha") @@ -65,6 +67,7 @@ dependencies { api("io.opentelemetry.contrib:opentelemetry-span-stacktrace:$otelJavaContribVersion") api("io.opentelemetry.contrib:opentelemetry-cel-sampler:$otelJavaContribVersion") + api("io.opentelemetry.contrib:opentelemetry-azure-resources:$otelJavaContribVersion") api("io.opentelemetry.semconv:opentelemetry-semconv-incubating:$opentelemetrySemconvAlpha") api("io.opentelemetry:opentelemetry-api-incubator:$opentelemetryAlpha") diff --git a/libs/core/build.gradle.kts b/libs/core/build.gradle.kts index fc1753b6..95cc13c2 100644 --- a/libs/core/build.gradle.kts +++ b/libs/core/build.gradle.kts @@ -19,6 +19,11 @@ dependencies { compileOnly("io.opentelemetry:opentelemetry-api") compileOnly("io.opentelemetry:opentelemetry-context") + // Azure resource detection is provided by the shaded :libs:resource module. Only the facade + // (plain JDK types) is needed at compile time; the relocated, self-contained shaded jar is + // placed on the bootstrap class loader by the agent build at runtime. + compileOnly(project(":libs:resource")) + implementation("javax.xml.bind:jaxb-api:2.3.1") implementation("com.solarwinds:apm-proto:1.0.8") { @@ -32,6 +37,7 @@ dependencies { testImplementation("org.json:json") testImplementation("io.opentelemetry:opentelemetry-api") testImplementation("io.opentelemetry:opentelemetry-context") + testRuntimeOnly(project(path = ":libs:resource", configuration = "shadow")) } sourceSets { diff --git a/libs/core/src/main/java/com/solarwinds/joboe/core/util/ServerHostInfoReader.java b/libs/core/src/main/java/com/solarwinds/joboe/core/util/ServerHostInfoReader.java index 8ffc1439..d3a210dc 100644 --- a/libs/core/src/main/java/com/solarwinds/joboe/core/util/ServerHostInfoReader.java +++ b/libs/core/src/main/java/com/solarwinds/joboe/core/util/ServerHostInfoReader.java @@ -21,6 +21,7 @@ import com.solarwinds.joboe.core.HostId; import com.solarwinds.joboe.logging.Logger; import com.solarwinds.joboe.logging.LoggerFactory; +import com.solarwinds.joboe.resource.AzureResourceReader; import io.opentelemetry.api.internal.InstrumentationUtil; import java.io.BufferedReader; import java.io.File; @@ -1098,7 +1099,6 @@ public static class AzureReader { @Getter(lazy = true) private static final AzureReader instance = new AzureReader(); - private static final String DEFAULT_METADATA_VERSION = "2021-12-13"; private final String appInstanceId; private final HostId.AzureVmMetadata azureVmMetadata; @@ -1107,63 +1107,96 @@ public static String getAppInstanceId() { return getInstance().appInstanceId; } - private HostId.AzureVmMetadata getVmMetadata() { - Integer timeout = - ConfigManager.getConfigOptional( - ConfigProperty.AGENT_AZURE_VM_METADATA_TIMEOUT, TIMEOUT_DEFAULT); - String metadataVersionCfg = - (String) ConfigManager.getConfig(ConfigProperty.AGENT_AZURE_VM_METADATA_VERSION); - - final String metadataVersion = - metadataVersionCfg != null ? metadataVersionCfg : DEFAULT_METADATA_VERSION; - AtomicReference result = new AtomicReference<>(); - - InstrumentationUtil.suppressInstrumentation( - () -> { - HttpURLConnection connection = null; - try { - URL url = - new URL( - String.format( - "%s%s%s", - METADATA_SERVICE_URL, - "/metadata/instance?api-version=", - metadataVersion)); - - connection = (HttpURLConnection) url.openConnection(Proxy.NO_PROXY); - connection.setRequestMethod("GET"); - connection.setReadTimeout(timeout); - - connection.setConnectTimeout(timeout); - connection.setRequestProperty("Metadata", "true"); - int statusCode = connection.getResponseCode(); + private static HostId.AzureVmMetadata buildVmMetadata(Map attrs) { + String hostId = attrs.get("host.id"); + if (hostId == null) { + return null; + } + String cloudAccountId = null; + String azureResourceGroupName = null; + String resourceId = attrs.get("cloud.resource_id"); + if (resourceId != null) { + String[] parts = resourceId.split("/"); + if (parts.length > 2) { + cloudAccountId = parts[2]; + } + if (parts.length > 4) { + azureResourceGroupName = parts[4]; + } + } + String hostName = attrs.get("host.name"); + return HostId.AzureVmMetadata.builder() + .cloudPlatform("azure.vm") + .cloudRegion(attrs.get("cloud.region")) + .hostId(hostId) + .hostName(hostName) + .azureVmName(hostName) + .cloudAccountId(cloudAccountId) + .azureVmSize(attrs.get("azure.vm.sku")) + .azureVmScaleSetName(attrs.get("azure.vm.scaleset.name")) + .azureResourceGroupName(azureResourceGroupName) + .build(); + } - logger.debug(String.format("Azure IMDS status code: %s", statusCode)); - if (statusCode >= 200 && statusCode < 300) { - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(connection.getInputStream()))) { - StringBuilder sb = new StringBuilder(); + private static HostId.AzureVmMetadata buildAppServiceMetadata(Map attrs) { + String cloudAccountId = null; + String azureResourceGroupName = null; + String resourceId = attrs.get("cloud.resource_id"); + if (resourceId != null) { + String[] parts = resourceId.split("/"); + if (parts.length > 2) { + cloudAccountId = parts[2]; + } + if (parts.length > 4) { + azureResourceGroupName = parts[4]; + } + } + return HostId.AzureVmMetadata.builder() + .cloudPlatform("azure.app_service") + .cloudRegion(attrs.get("cloud.region")) + .hostId(attrs.get("service.instance.id")) + .hostName(attrs.get("host.id")) + .cloudAccountId(cloudAccountId) + .azureResourceGroupName(azureResourceGroupName) + .build(); + } - String line; - while ((line = reader.readLine()) != null) { - sb.append(line); - } + private static HostId.AzureVmMetadata buildFunctionsMetadata(Map attrs) { + return HostId.AzureVmMetadata.builder() + .cloudPlatform("azure.functions") + .cloudRegion(attrs.get("cloud.region")) + .hostId(attrs.get("faas.instance")) + .hostName(attrs.get("faas.name")) + .build(); + } - String payload = sb.toString(); - logger.debug(String.format("Azure IMDS payload: %s", payload)); - result.set(HostId.AzureVmMetadata.fromJson(payload)); - } - } + private static HostId.AzureVmMetadata buildContainerAppMetadata(Map attrs) { + return HostId.AzureVmMetadata.builder() + .cloudPlatform("azure.container_apps") + .hostId(attrs.get("service.instance.id")) + .hostName(attrs.get("service.name")) + .build(); + } - } catch (IOException | JSONException exception) { - logger.debug("Error retrieving vmId from IMDS", exception); - } finally { - if (connection != null) { - connection.disconnect(); - } - } - }); - return result.get(); + private HostId.AzureVmMetadata getAzureMetadata(String platform) { + AtomicReference> attrsRef = new AtomicReference<>(); + InstrumentationUtil.suppressInstrumentation( + () -> attrsRef.set(AzureResourceReader.detectAttributes())); + Map attrs = attrsRef.get(); + if (attrs == null || attrs.isEmpty()) { + return null; + } + switch (platform) { + case "APP_SERVICE": + return buildAppServiceMetadata(attrs); + case "FUNCTIONS": + return buildFunctionsMetadata(attrs); + case "CONTAINER_APP": + return buildContainerAppMetadata(attrs); + case "NONE": + default: + return buildVmMetadata(attrs); + } } private AzureReader() { @@ -1171,7 +1204,8 @@ private AzureReader() { if (this.appInstanceId != null) { logger.debug("Found Azure instance ID: " + this.appInstanceId); } - azureVmMetadata = getVmMetadata(); + String platform = AzureResourceReader.detectPlatform(); + this.azureVmMetadata = getAzureMetadata(platform); logger.debug(String.format("Azure vm metadata: %s", azureVmMetadata)); } } diff --git a/libs/resource/build.gradle.kts b/libs/resource/build.gradle.kts new file mode 100644 index 00000000..b90cb924 --- /dev/null +++ b/libs/resource/build.gradle.kts @@ -0,0 +1,58 @@ +/* + * © SolarWinds Worldwide, LLC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id("solarwinds.java-conventions") + id("com.gradleup.shadow") +} + +description = "shaded azure resource detection" + +// The OpenTelemetry azure-resources detector is only consumable through the SDK +// autoconfigure SPI (ComponentProvider / ConditionalResourceProvider). Those SPI +// classes are not resolvable from the joboe core bootstrap class loader at premain. +// To use the detector from there, we bundle it together with the OpenTelemetry SDK +// it depends on and relocate everything into a private namespace so it is fully +// self-contained and cannot collide with the agent's own (unrelocated) SDK that +// lives in the agent class loader. +dependencies { + implementation("io.opentelemetry.contrib:opentelemetry-azure-resources") + implementation("io.opentelemetry:opentelemetry-sdk") + implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") + implementation("io.opentelemetry:opentelemetry-api") + implementation("io.opentelemetry:opentelemetry-api-incubator") + implementation("io.opentelemetry.semconv:opentelemetry-semconv") + implementation("io.opentelemetry.semconv:opentelemetry-semconv-incubating") +} + +tasks.withType().configureEach { + jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED" + ) +} + +tasks.withType().configureEach { + mergeServiceFiles() + // Relocate the bundled OpenTelemetry SDK + azure detector and its Jackson + // dependency into a private namespace. The public facade in + // com.solarwinds.joboe.resource is intentionally left unrelocated. + relocate("io.opentelemetry", "com.solarwinds.joboe.shaded.azure.io.opentelemetry") + relocate("com.fasterxml.jackson", "com.solarwinds.joboe.shaded.azure.com.fasterxml.jackson") + exclude("**/module-info.class") +} diff --git a/libs/resource/src/main/java/com/solarwinds/joboe/resource/AzureResourceReader.java b/libs/resource/src/main/java/com/solarwinds/joboe/resource/AzureResourceReader.java new file mode 100644 index 00000000..b8314d61 --- /dev/null +++ b/libs/resource/src/main/java/com/solarwinds/joboe/resource/AzureResourceReader.java @@ -0,0 +1,65 @@ +/* + * © SolarWinds Worldwide, LLC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.solarwinds.joboe.resource; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; +import io.opentelemetry.contrib.azure.resource.AzureEnvVarPlatform; +import io.opentelemetry.contrib.azure.resource.AzureResourceDetector; +import io.opentelemetry.sdk.resources.Resource; +import java.util.HashMap; +import java.util.Map; + +/** + * Facade over the OpenTelemetry {@code azure-resources} detector. + * + *

This class is the single point that touches the OpenTelemetry SDK autoconfigure SPI. All + * OpenTelemetry types referenced here are relocated into a private namespace when this module is + * shaded, so callers receive only plain JDK types and never link against the SPI classes that are + * unavailable in the joboe core bootstrap class loader. + */ +public final class AzureResourceReader { + + private AzureResourceReader() {} + + /** + * Detects the Azure platform from environment variables. + * + * @return the platform name, one of {@code APP_SERVICE}, {@code FUNCTIONS}, {@code CONTAINER_APP} + * or {@code NONE} + */ + public static String detectPlatform() { + return AzureEnvVarPlatform.detect(System.getenv()).name(); + } + + /** + * Runs the Azure resource detector and flattens the detected resource into a string map keyed by + * OpenTelemetry attribute key. + * + *

This may perform an HTTP call to the Azure instance metadata service for the plain VM case. + * Callers are responsible for suppressing instrumentation around this call. + * + * @return a map of attribute key to value; empty if nothing is detected + */ + public static Map detectAttributes() { + Resource resource = new AzureResourceDetector().create(DeclarativeConfigProperties.empty()); + Attributes attributes = resource.getAttributes(); + Map result = new HashMap<>(attributes.size()); + attributes.forEach((key, value) -> result.put(key.getKey(), String.valueOf(value))); + return result; + } +} diff --git a/libs/resource/src/test/java/com/solarwinds/joboe/resource/AzureResourceReaderTest.java b/libs/resource/src/test/java/com/solarwinds/joboe/resource/AzureResourceReaderTest.java new file mode 100644 index 00000000..4e5b4d2c --- /dev/null +++ b/libs/resource/src/test/java/com/solarwinds/joboe/resource/AzureResourceReaderTest.java @@ -0,0 +1,111 @@ +/* + * © SolarWinds Worldwide, LLC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.solarwinds.joboe.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.ClearEnvironmentVariable; +import org.junitpioneer.jupiter.SetEnvironmentVariable; + +class AzureResourceReaderTest { + + @Test + @ClearEnvironmentVariable(key = "CONTAINER_APP_NAME") + @ClearEnvironmentVariable(key = "WEBSITE_SITE_NAME") + @ClearEnvironmentVariable(key = "FUNCTIONS_EXTENSION_VERSION") + void detectPlatformreturnsNonewhenNoAzureEnvVarsPresent() { + assertEquals("NONE", AzureResourceReader.detectPlatform()); + } + + @Test + @ClearEnvironmentVariable(key = "CONTAINER_APP_NAME") + @ClearEnvironmentVariable(key = "FUNCTIONS_EXTENSION_VERSION") + @SetEnvironmentVariable(key = "WEBSITE_SITE_NAME", value = "my-app") + void detectPlatformreturnsAppServicewhenOnlyWebsiteSiteNameSet() { + assertEquals("APP_SERVICE", AzureResourceReader.detectPlatform()); + } + + @Test + @ClearEnvironmentVariable(key = "CONTAINER_APP_NAME") + @SetEnvironmentVariable(key = "WEBSITE_SITE_NAME", value = "my-func") + @SetEnvironmentVariable(key = "FUNCTIONS_EXTENSION_VERSION", value = "~4") + void detectPlatformreturnsFunctionswhenFunctionsExtensionVersionSet() { + assertEquals("FUNCTIONS", AzureResourceReader.detectPlatform()); + } + + @Test + @SetEnvironmentVariable(key = "CONTAINER_APP_NAME", value = "my-container") + void detectPlatformreturnsContainerAppwhenContainerAppNameSet() { + assertEquals("CONTAINER_APP", AzureResourceReader.detectPlatform()); + } + + @Test + @ClearEnvironmentVariable(key = "CONTAINER_APP_NAME") + @ClearEnvironmentVariable(key = "FUNCTIONS_EXTENSION_VERSION") + @SetEnvironmentVariable(key = "WEBSITE_SITE_NAME", value = "my-app") + void detectAttributesreturnsAppServiceAttributeswhenWebsiteSiteNameSet() { + Map attributes = AzureResourceReader.detectAttributes(); + + assertEquals("azure", attributes.get("cloud.provider")); + assertEquals("azure.app_service", attributes.get("cloud.platform")); + assertEquals("my-app", attributes.get("service.name")); + } + + @Test + @ClearEnvironmentVariable(key = "CONTAINER_APP_NAME") + @SetEnvironmentVariable(key = "WEBSITE_SITE_NAME", value = "my-func") + @SetEnvironmentVariable(key = "FUNCTIONS_EXTENSION_VERSION", value = "~4") + void detectAttributesreturnsFunctionsAttributeswhenFunctionsExtensionVersionSet() { + Map attributes = AzureResourceReader.detectAttributes(); + + assertEquals("azure", attributes.get("cloud.provider")); + assertEquals("azure.functions", attributes.get("cloud.platform")); + assertEquals("my-func", attributes.get("faas.name")); + assertEquals("~4", attributes.get("faas.version")); + } + + @Test + @SetEnvironmentVariable(key = "CONTAINER_APP_NAME", value = "my-container") + @SetEnvironmentVariable(key = "CONTAINER_APP_REPLICA_NAME", value = "replica-001") + @SetEnvironmentVariable(key = "CONTAINER_APP_REVISION", value = "v1") + void detectAttributesreturnsContainerAppAttributeswhenContainerAppNameSet() { + Map attributes = AzureResourceReader.detectAttributes(); + + assertEquals("azure", attributes.get("cloud.provider")); + assertEquals("azure.container_apps", attributes.get("cloud.platform")); + assertEquals("my-container", attributes.get("service.name")); + assertEquals("replica-001", attributes.get("service.instance.id")); + assertEquals("v1", attributes.get("service.version")); + } + + @Test + @ClearEnvironmentVariable(key = "CONTAINER_APP_NAME") + @ClearEnvironmentVariable(key = "FUNCTIONS_EXTENSION_VERSION") + @SetEnvironmentVariable(key = "WEBSITE_SITE_NAME", value = "my-app") + @SetEnvironmentVariable(key = "WEBSITE_INSTANCE_ID", value = "instance-123") + @SetEnvironmentVariable(key = "REGION_NAME", value = "eastus") + void detectAttributesincludesRegionAndInstanceIdwhenEnvVarsSet() { + Map attributes = AzureResourceReader.detectAttributes(); + + assertEquals("eastus", attributes.get("cloud.region")); + assertEquals("instance-123", attributes.get("service.instance.id")); + assertTrue(attributes.containsKey("cloud.platform")); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 85dc7d02..8c04d21a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,7 @@ include("testing:agent-for-testing") include("testing:agent-test-extension") include("dependencyManagement") include("libs:core") +include("libs:resource") include("libs:config") include("libs:logging") include("libs:sampling") diff --git a/testing/agent-for-testing/build.gradle.kts b/testing/agent-for-testing/build.gradle.kts index 3ebf0cbc..e9fcbbb0 100644 --- a/testing/agent-for-testing/build.gradle.kts +++ b/testing/agent-for-testing/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { bootstrapLibs(project(":bootstrap")) bootstrapLibs(project(":libs:core")) + bootstrapLibs(project(path = ":libs:resource", configuration = "shadow")) bootstrapLibs(project(":libs:config")) bootstrapLibs(project(":libs:sampling"))