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>
This commit is contained in:
parent
0f3fd2e69d
commit
56f4e52a64
|
@ -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")
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
server.port=777
|
||||
server.context-path=/meow
|
||||
spring.application.name=dog-store
|
|
@ -0,0 +1,14 @@
|
|||
flib:
|
||||
something:
|
||||
12
|
||||
|
||||
section:
|
||||
two: 2
|
||||
|
||||
server:
|
||||
port: 777
|
||||
context-path: /meow
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: cat-store
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue