diff --git a/instrumentation/spring/spring-boot-resources/library/build.gradle.kts b/instrumentation/spring/spring-boot-resources/library/build.gradle.kts new file mode 100644 index 000000000000..5e3dbc6a77eb --- /dev/null +++ b/instrumentation/spring/spring-boot-resources/library/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("otel.library-instrumentation") +} + +dependencies { + compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") + + annotationProcessor("com.google.auto.service:auto-service") + compileOnly("com.google.auto.service:auto-service-annotations") + testCompileOnly("com.google.auto.service:auto-service-annotations") + + implementation("org.yaml:snakeyaml:1.31") + + testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") +} diff --git a/instrumentation/spring/spring-boot-resources/library/src/main/java/io/opentelemetry/instrumentation/spring/resources/SpringBootServiceNameGuesser.java b/instrumentation/spring/spring-boot-resources/library/src/main/java/io/opentelemetry/instrumentation/spring/resources/SpringBootServiceNameGuesser.java new file mode 100644 index 000000000000..8947e1bb12a3 --- /dev/null +++ b/instrumentation/spring/spring-boot-resources/library/src/main/java/io/opentelemetry/instrumentation/spring/resources/SpringBootServiceNameGuesser.java @@ -0,0 +1,280 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.spring.resources; + +import com.google.auto.service.AutoService; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.semconv.resource.attributes.ResourceAttributes; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Properties; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.yaml.snakeyaml.Yaml; + +/** + * A ResourceProvider that will attempt to guess the application name for a Spring Boot service. + * When successful, it will return a Resource that has the service name attribute populated with the + * name of the Spring Boot application. It uses the following strategies, and the first successful + * strategy wins: + * + * + */ +@AutoService(ResourceProvider.class) +public class SpringBootServiceNameGuesser implements ResourceProvider { + + private static final Logger logger = + Logger.getLogger(SpringBootServiceNameGuesser.class.getName()); + private static final String COMMANDLINE_ARG_PREFIX = "--spring.application.name="; + private static final Pattern COMMANDLINE_PATTERN = + Pattern.compile("--spring\\.application\\.name=([a-zA-Z.\\-_]+)"); + private final SystemHelper system; + + @SuppressWarnings("unused") + public SpringBootServiceNameGuesser() { + this(new SystemHelper()); + } + + // Exists for testing + SpringBootServiceNameGuesser(SystemHelper system) { + this.system = system; + } + + @Override + public Resource createResource(ConfigProperties config) { + + logger.log(Level.FINER, "Performing Spring Boot service name auto-detection..."); + // Note: The order should be consistent with the order of Spring matching, but noting + // that we have "first one wins" while Spring has "last one wins". + // The docs for Spring are here: + // https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.external-config + Stream> finders = + Stream.of( + this::findByCommandlineArgument, + this::findBySystemProperties, + this::findByEnvironmentVariable, + this::findByCurrentDirectoryApplicationProperties, + this::findByCurrentDirectoryApplicationYaml, + this::findByClasspathApplicationProperties, + this::findByClasspathApplicationYaml); + return finders + .map(Supplier::get) + .filter(Objects::nonNull) + .findFirst() + .map( + serviceName -> { + logger.log(Level.FINER, "Guessed Spring Boot service name: {0}", serviceName); + return Resource.builder().put(ResourceAttributes.SERVICE_NAME, serviceName).build(); + }) + .orElseGet(Resource::empty); + } + + @Nullable + private String findByEnvironmentVariable() { + String result = system.getenv("SPRING_APPLICATION_NAME"); + logger.log(Level.FINER, "Checking for SPRING_APPLICATION_NAME in env: {0}", result); + return result; + } + + @Nullable + private String findBySystemProperties() { + String result = system.getProperty("spring.application.name"); + logger.log(Level.FINER, "Checking for spring.application.name system property: {0}", result); + return result; + } + + @Nullable + private String findByClasspathApplicationProperties() { + String result = readNameFromAppProperties(); + logger.log( + Level.FINER, + "Checking for spring.application.name in application.properties file: {0}", + result); + return result; + } + + @Nullable + private String findByCurrentDirectoryApplicationProperties() { + String result = null; + try (InputStream in = system.openFile("application.properties")) { + result = getAppNamePropertyFromStream(in); + } catch (Exception e) { + // expected to fail sometimes + } + logger.log(Level.FINER, "Checking application.properties in current dir: {0}", result); + return result; + } + + @Nullable + private String findByClasspathApplicationYaml() { + String result = + loadFromClasspath("application.yml", SpringBootServiceNameGuesser::parseNameFromYaml); + logger.log(Level.FINER, "Checking application.yml in classpath: {0}", result); + return result; + } + + @Nullable + private String findByCurrentDirectoryApplicationYaml() { + String result = null; + try (InputStream in = system.openFile("application.yml")) { + result = parseNameFromYaml(in); + } catch (Exception e) { + // expected to fail sometimes + } + logger.log(Level.FINER, "Checking application.yml in current dir: {0}", result); + return result; + } + + @Nullable + @SuppressWarnings("unchecked") + private static String parseNameFromYaml(InputStream in) { + Yaml yaml = new Yaml(); + try { + Map data = yaml.load(in); + Map> spring = + (Map>) data.get("spring"); + if (spring != null) { + Map app = spring.get("application"); + if (app != null) { + Object name = app.get("name"); + return (String) name; + } + } + } catch (RuntimeException e) { + // expected to fail sometimes + } + return null; + } + + @Nullable + private String findByCommandlineArgument() { + String result = attemptProcessHandleReflection(); + if (result == null) { + String javaCommand = system.getProperty("sun.java.command"); + result = parseNameFromCommandLine(javaCommand); + } + logger.log(Level.FINER, "Checking application commandline args: {0}", result); + return result; + } + + @Nullable + private String attemptProcessHandleReflection() { + try { + String[] args = system.attemptGetCommandLineArgsViaReflection(); + return parseNameFromProcessArgs(args); + } catch (Exception e) { + return null; + } + } + + @Nullable + private static String parseNameFromCommandLine(@Nullable String commandLine) { + if (commandLine == null) { + return null; + } + Matcher matcher = COMMANDLINE_PATTERN.matcher(commandLine); + if (matcher.find()) { // Required before group() + return matcher.group(1); + } + return null; + } + + @Nullable + private static String parseNameFromProcessArgs(String[] args) { + return Stream.of(args) + .filter(arg -> arg.startsWith(COMMANDLINE_ARG_PREFIX)) + .map(arg -> arg.substring(COMMANDLINE_ARG_PREFIX.length())) + .findFirst() + .orElse(null); + } + + @Nullable + private String readNameFromAppProperties() { + return loadFromClasspath( + "application.properties", SpringBootServiceNameGuesser::getAppNamePropertyFromStream); + } + + @Nullable + private static String getAppNamePropertyFromStream(InputStream in) { + Properties properties = new Properties(); + try { + // Note: load() uses ISO 8859-1 encoding, same as spring uses by default for property files + properties.load(in); + return properties.getProperty("spring.application.name"); + } catch (IOException e) { + return null; + } + } + + @Nullable + private String loadFromClasspath(String filename, Function parser) { + try (InputStream in = system.openClasspathResource(filename)) { + return parser.apply(in); + } catch (Exception e) { + return null; + } + } + + // Exists for testing + static class SystemHelper { + + String getenv(String name) { + return System.getenv(name); + } + + String getProperty(String key) { + return System.getProperty(key); + } + + InputStream openClasspathResource(String filename) { + return ClassLoader.getSystemClassLoader().getResourceAsStream(filename); + } + + InputStream openFile(String filename) throws Exception { + return Files.newInputStream(Paths.get(filename)); + } + + /** + * Attempts to use ProcessHandle to get the full commandline of the current process (including + * the main method arguments). Will only succeed on java 9+. + */ + @SuppressWarnings("unchecked") + String[] attemptGetCommandLineArgsViaReflection() throws Exception { + Class clazz = Class.forName("java.lang.ProcessHandle"); + Method currentMethod = clazz.getDeclaredMethod("current"); + Method infoMethod = clazz.getDeclaredMethod("info"); + Object currentInstance = currentMethod.invoke(null); + Object info = infoMethod.invoke(currentInstance); + Class infoClass = Class.forName("java.lang.ProcessHandle$Info"); + Method argumentsMethod = infoClass.getMethod("arguments"); + Optional optionalArgs = (Optional) argumentsMethod.invoke(info); + return optionalArgs.orElse(new String[0]); + } + } +} diff --git a/instrumentation/spring/spring-boot-resources/library/src/test/java/io/opentelemetry/instrumentation/spring/resources/SpringBootServiceNameGuesserTest.java b/instrumentation/spring/spring-boot-resources/library/src/test/java/io/opentelemetry/instrumentation/spring/resources/SpringBootServiceNameGuesserTest.java new file mode 100644 index 000000000000..241af2fb532e --- /dev/null +++ b/instrumentation/spring/spring-boot-resources/library/src/test/java/io/opentelemetry/instrumentation/spring/resources/SpringBootServiceNameGuesserTest.java @@ -0,0 +1,128 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.spring.resources; + +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.SERVICE_NAME; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.resources.Resource; +import java.io.OutputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SpringBootServiceNameGuesserTest { + + static final String PROPS = "application.properties"; + static final String APPLICATION_YML = "application.yml"; + @Mock ConfigProperties config; + @Mock SpringBootServiceNameGuesser.SystemHelper system; + + @Test + void findByEnvVar() { + String expected = "fur-city"; + when(system.getenv("SPRING_APPLICATION_NAME")).thenReturn(expected); + + SpringBootServiceNameGuesser guesser = new SpringBootServiceNameGuesser(system); + + Resource result = guesser.createResource(config); + expectServiceName(result, expected); + } + + @Test + void classpathApplicationProperties() { + when(system.openClasspathResource(PROPS)).thenCallRealMethod(); + SpringBootServiceNameGuesser guesser = new SpringBootServiceNameGuesser(system); + Resource result = guesser.createResource(config); + expectServiceName(result, "dog-store"); + } + + @Test + void propertiesFileInCurrentDir() throws Exception { + Path propsPath = Paths.get(PROPS); + try { + writeString(propsPath, "spring.application.name=fish-tank\n"); + when(system.openFile(PROPS)).thenCallRealMethod(); + SpringBootServiceNameGuesser guesser = new SpringBootServiceNameGuesser(system); + Resource result = guesser.createResource(config); + expectServiceName(result, "fish-tank"); + } finally { + Files.delete(propsPath); + } + } + + @Test + void classpathApplicationYaml() { + when(system.openClasspathResource(APPLICATION_YML)).thenCallRealMethod(); + SpringBootServiceNameGuesser guesser = new SpringBootServiceNameGuesser(system); + Resource result = guesser.createResource(config); + expectServiceName(result, "cat-store"); + } + + @Test + void yamlFileInCurrentDir() throws Exception { + Path yamlPath = Paths.get(APPLICATION_YML); + try { + URL url = getClass().getClassLoader().getResource(APPLICATION_YML); + String content = readString(Paths.get(url.toURI())); + writeString(yamlPath, content); + when(system.openFile(APPLICATION_YML)).thenCallRealMethod(); + SpringBootServiceNameGuesser guesser = new SpringBootServiceNameGuesser(system); + Resource result = guesser.createResource(config); + expectServiceName(result, "cat-store"); + } finally { + Files.delete(yamlPath); + } + } + + @Test + void getFromCommandlineArgsWithProcessHandle() throws Exception { + when(system.attemptGetCommandLineArgsViaReflection()) + .thenReturn( + new String[] { + "/bin/java", + "sweet-spring.jar", + "--spring.application.name=tiger-town", + "--quiet=never" + }); + SpringBootServiceNameGuesser guesser = new SpringBootServiceNameGuesser(system); + Resource result = guesser.createResource(config); + expectServiceName(result, "tiger-town"); + } + + @Test + void getFromCommandlineArgsWithSystemProperty() throws Exception { + when(system.getProperty("sun.java.command")) + .thenReturn("/bin/java sweet-spring.jar --spring.application.name=bullpen --quiet=never"); + SpringBootServiceNameGuesser guesser = new SpringBootServiceNameGuesser(system); + Resource result = guesser.createResource(config); + expectServiceName(result, "bullpen"); + } + + private static void expectServiceName(Resource result, String expected) { + assertThat(result.getAttribute(SERVICE_NAME)).isEqualTo(expected); + } + + private static void writeString(Path path, String value) throws Exception { + try (OutputStream out = Files.newOutputStream(path)) { + out.write(value.getBytes(UTF_8)); + } + } + + private static String readString(Path path) throws Exception { + byte[] allBytes = Files.readAllBytes(path); + return new String(allBytes, UTF_8); + } +} diff --git a/instrumentation/spring/spring-boot-resources/library/src/test/resources/application.properties b/instrumentation/spring/spring-boot-resources/library/src/test/resources/application.properties new file mode 100644 index 000000000000..1b5b5c8d01cc --- /dev/null +++ b/instrumentation/spring/spring-boot-resources/library/src/test/resources/application.properties @@ -0,0 +1,3 @@ +server.port=777 +server.context-path=/meow +spring.application.name=dog-store diff --git a/instrumentation/spring/spring-boot-resources/library/src/test/resources/application.yml b/instrumentation/spring/spring-boot-resources/library/src/test/resources/application.yml new file mode 100644 index 000000000000..3bfd3386b84e --- /dev/null +++ b/instrumentation/spring/spring-boot-resources/library/src/test/resources/application.yml @@ -0,0 +1,14 @@ +flib: + something: + 12 + +section: + two: 2 + +server: + port: 777 + context-path: /meow + +spring: + application: + name: cat-store \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e085ca97ad58..a7093a514501 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -423,6 +423,7 @@ include(":instrumentation:servlet:servlet-5.0:javaagent") include(":instrumentation:spark-2.3:javaagent") include(":instrumentation:spring:spring-batch-3.0:javaagent") include(":instrumentation:spring:spring-boot-actuator-autoconfigure-2.0:javaagent") +include(":instrumentation:spring:spring-boot-resources:library") include(":instrumentation:spring:spring-core-2.0:javaagent") include(":instrumentation:spring:spring-data-1.8:javaagent") include(":instrumentation:spring:spring-integration-4.1:javaagent")