Parcourir la source

Add Spring Boot service name guesser / ResourceProvider (#6516)

* Add spring boot service name guesser.

* add encoding

* improve commandline handling

* move guesser to own module

* use readAllBytes which exists in java 8

* spotless

* add note and link to spring docs

* group for readability

* repackage

* Apply suggestions from code review

Co-authored-by: Trask Stalnaker <trask.stalnaker@gmail.com>

* code review comments

Co-authored-by: Mateusz Rzeszutek <mrzeszutek@splunk.com>
Co-authored-by: Trask Stalnaker <trask.stalnaker@gmail.com>
jason plumb il y a 2 ans
Parent
commit
56f4e52a64

+ 15 - 0
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")
+}

+ 280 - 0
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:
+ *
+ * <ul>
+ *   <li>Check for the SPRING_APPLICATION_NAME environment variable
+ *   <li>Check for spring.application.name system property
+ *   <li>Check for application.properties file on the classpath
+ *   <li>Check for application.properties in the current working dir
+ *   <li>Check for application.yml on the classpath
+ *   <li>Check for application.yml in the current working dir
+ *   <li>Check for --spring.application.name program argument (not jvm arg) via ProcessHandle
+ *   <li>Check for --spring.application.name program argument via sun.java.command system property
+ * </ul>
+ */
+@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<Supplier<String>> 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<String, Object> data = yaml.load(in);
+      Map<String, Map<String, Object>> spring =
+          (Map<String, Map<String, Object>>) data.get("spring");
+      if (spring != null) {
+        Map<String, Object> 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<InputStream, String> 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<String[]> optionalArgs = (Optional<String[]>) argumentsMethod.invoke(info);
+      return optionalArgs.orElse(new String[0]);
+    }
+  }
+}

+ 128 - 0
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);
+  }
+}

+ 3 - 0
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

+ 14 - 0
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

+ 1 - 0
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")