Manifest resource detector (#10621)

Co-authored-by: Trask Stalnaker <trask.stalnaker@gmail.com>
This commit is contained in:
Gregor Zeitlinger 2024-03-13 21:12:35 +01:00 committed by GitHub
parent 2701c4dc68
commit 5df8a5a0a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 488 additions and 95 deletions

View File

@ -0,0 +1,28 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.instrumentation.resources;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import io.opentelemetry.api.common.AttributeKey;
import java.util.Optional;
import java.util.function.Function;
/**
* An easier alternative to {@link io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider}, which
* avoids some common pitfalls and boilerplate.
*
* <p>An example of how to use this interface can be found in {@link ManifestResourceProvider}.
*/
interface AttributeProvider<D> {
Optional<D> readData();
void registerAttributes(Builder<D> builder);
interface Builder<D> {
@CanIgnoreReturnValue
<T> Builder<D> add(AttributeKey<T> key, Function<D, Optional<T>> getter);
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.instrumentation.resources;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
import io.opentelemetry.sdk.autoconfigure.spi.internal.ConditionalResourceProvider;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.semconv.ResourceAttributes;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
/**
* An easier alternative to {@link io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider}, which
* avoids some common pitfalls and boilerplate.
*
* <p>An example of how to use this interface can be found in {@link ManifestResourceProvider}.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public abstract class AttributeResourceProvider<D> implements ConditionalResourceProvider {
private final AttributeProvider<D> attributeProvider;
public class AttributeBuilder implements AttributeProvider.Builder<D> {
private AttributeBuilder() {}
@CanIgnoreReturnValue
@Override
public <T> AttributeBuilder add(AttributeKey<T> key, Function<D, Optional<T>> getter) {
attributeGetters.put((AttributeKey) key, Objects.requireNonNull((Function) getter));
return this;
}
}
private static final ThreadLocal<Resource> existingResource = new ThreadLocal<>();
private final Map<AttributeKey<Object>, Function<D, Optional<?>>> attributeGetters =
new HashMap<>();
public AttributeResourceProvider(AttributeProvider<D> attributeProvider) {
this.attributeProvider = attributeProvider;
attributeProvider.registerAttributes(new AttributeBuilder());
}
@Override
public final boolean shouldApply(ConfigProperties config, Resource existing) {
existingResource.set(existing);
Map<String, String> resourceAttributes = getResourceAttributes(config);
return attributeGetters.keySet().stream()
.allMatch(key -> shouldUpdate(config, existing, key, resourceAttributes));
}
@Override
public final Resource createResource(ConfigProperties config) {
return attributeProvider
.readData()
.map(
data -> {
// what should we do here?
// we don't have access to the existing resource
// if the resource provider produces a single key, we can rely on shouldApply
// i.e. this method won't be called if the key is already present
// the thread local is a hack to work around this
Resource existing =
Objects.requireNonNull(existingResource.get(), "call shouldApply first");
Map<String, String> resourceAttributes = getResourceAttributes(config);
AttributesBuilder builder = Attributes.builder();
attributeGetters.entrySet().stream()
.filter(e -> shouldUpdate(config, existing, e.getKey(), resourceAttributes))
.forEach(
e ->
e.getValue()
.apply(data)
.ifPresent(value -> putAttribute(builder, e.getKey(), value)));
return Resource.create(builder.build());
})
.orElse(Resource.empty());
}
private static <T> void putAttribute(AttributesBuilder builder, AttributeKey<T> key, T value) {
builder.put(key, value);
}
private static Map<String, String> getResourceAttributes(ConfigProperties config) {
return config.getMap("otel.resource.attributes");
}
private static boolean shouldUpdate(
ConfigProperties config,
Resource existing,
AttributeKey<?> key,
Map<String, String> resourceAttributes) {
if (resourceAttributes.containsKey(key.getKey())) {
return false;
}
Object value = existing.getAttribute(key);
if (key.equals(ResourceAttributes.SERVICE_NAME)) {
return config.getString("otel.service.name") == null && "unknown_service:java".equals(value);
}
return value == null;
}
}

View File

@ -0,0 +1,112 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.instrumentation.resources;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import javax.annotation.Nullable;
class JarPathFinder {
private final Supplier<String[]> getProcessHandleArguments;
private final Function<String, String> getSystemProperty;
private final Predicate<Path> fileExists;
private static class DetectionResult {
private final Optional<Path> jarPath;
private DetectionResult(Optional<Path> jarPath) {
this.jarPath = jarPath;
}
}
private static Optional<DetectionResult> detectionResult = Optional.empty();
public JarPathFinder() {
this(ProcessArguments::getProcessArguments, System::getProperty, Files::isRegularFile);
}
// visible for tests
JarPathFinder(
Supplier<String[]> getProcessHandleArguments,
Function<String, String> getSystemProperty,
Predicate<Path> fileExists) {
this.getProcessHandleArguments = getProcessHandleArguments;
this.getSystemProperty = getSystemProperty;
this.fileExists = fileExists;
}
// visible for testing
static void resetForTest() {
detectionResult = Optional.empty();
}
Optional<Path> getJarPath() {
if (!detectionResult.isPresent()) {
detectionResult = Optional.of(new DetectionResult(Optional.ofNullable(detectJarPath())));
}
return detectionResult.get().jarPath;
}
private Path detectJarPath() {
Path jarPath = getJarPathFromProcessHandle();
if (jarPath != null) {
return jarPath;
}
return getJarPathFromSunCommandLine();
}
@Nullable
private Path getJarPathFromProcessHandle() {
String[] javaArgs = getProcessHandleArguments.get();
for (int i = 0; i < javaArgs.length; ++i) {
if ("-jar".equals(javaArgs[i]) && (i < javaArgs.length - 1)) {
return Paths.get(javaArgs[i + 1]);
}
}
return null;
}
@Nullable
private Path getJarPathFromSunCommandLine() {
// the jar file is the first argument in the command line string
String programArguments = getSystemProperty.apply("sun.java.command");
if (programArguments == null) {
return null;
}
// Take the path until the first space. If the path doesn't exist extend it up to the next
// space. Repeat until a path that exists is found or input runs out.
int next = 0;
while (true) {
int nextSpace = programArguments.indexOf(' ', next);
if (nextSpace == -1) {
return pathIfExists(programArguments);
}
Path path = pathIfExists(programArguments.substring(0, nextSpace));
next = nextSpace + 1;
if (path != null) {
return path;
}
}
}
@Nullable
private Path pathIfExists(String programArguments) {
Path candidate;
try {
candidate = Paths.get(programArguments);
} catch (InvalidPathException e) {
return null;
}
return fileExists.test(candidate) ? candidate : null;
}
}

View File

@ -14,16 +14,9 @@ import io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider;
import io.opentelemetry.sdk.autoconfigure.spi.internal.ConditionalResourceProvider;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.semconv.ResourceAttributes;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.logging.Logger;
import javax.annotation.Nullable;
/**
* A {@link ResourceProvider} that will attempt to detect the application name from the jar name.
@ -33,37 +26,30 @@ public final class JarServiceNameDetector implements ConditionalResourceProvider
private static final Logger logger = Logger.getLogger(JarServiceNameDetector.class.getName());
private final Supplier<String[]> getProcessHandleArguments;
private final Function<String, String> getSystemProperty;
private final Predicate<Path> fileExists;
private final JarPathFinder jarPathFinder;
@SuppressWarnings("unused") // SPI
public JarServiceNameDetector() {
this(ProcessArguments::getProcessArguments, System::getProperty, Files::isRegularFile);
this(new JarPathFinder());
}
// visible for tests
JarServiceNameDetector(
Supplier<String[]> getProcessHandleArguments,
Function<String, String> getSystemProperty,
Predicate<Path> fileExists) {
this.getProcessHandleArguments = getProcessHandleArguments;
this.getSystemProperty = getSystemProperty;
this.fileExists = fileExists;
JarServiceNameDetector(JarPathFinder jarPathFinder) {
this.jarPathFinder = jarPathFinder;
}
@Override
public Resource createResource(ConfigProperties config) {
Path jarPath = getJarPathFromProcessHandle();
if (jarPath == null) {
jarPath = getJarPathFromSunCommandLine();
}
if (jarPath == null) {
return Resource.empty();
}
String serviceName = getServiceName(jarPath);
logger.log(FINE, "Auto-detected service name from the jar file name: {0}", serviceName);
return Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, serviceName));
return jarPathFinder
.getJarPath()
.map(
jarPath -> {
String serviceName = getServiceName(jarPath);
logger.log(
FINE, "Auto-detected service name from the jar file name: {0}", serviceName);
return Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, serviceName));
})
.orElseGet(Resource::empty);
}
@Override
@ -75,52 +61,6 @@ public final class JarServiceNameDetector implements ConditionalResourceProvider
&& "unknown_service:java".equals(existing.getAttribute(ResourceAttributes.SERVICE_NAME));
}
@Nullable
private Path getJarPathFromProcessHandle() {
String[] javaArgs = getProcessHandleArguments.get();
for (int i = 0; i < javaArgs.length; ++i) {
if ("-jar".equals(javaArgs[i]) && (i < javaArgs.length - 1)) {
return Paths.get(javaArgs[i + 1]);
}
}
return null;
}
@Nullable
private Path getJarPathFromSunCommandLine() {
// the jar file is the first argument in the command line string
String programArguments = getSystemProperty.apply("sun.java.command");
if (programArguments == null) {
return null;
}
// Take the path until the first space. If the path doesn't exist extend it up to the next
// space. Repeat until a path that exists is found or input runs out.
int next = 0;
while (true) {
int nextSpace = programArguments.indexOf(' ', next);
if (nextSpace == -1) {
return pathIfExists(programArguments);
}
Path path = pathIfExists(programArguments.substring(0, nextSpace));
next = nextSpace + 1;
if (path != null) {
return path;
}
}
}
@Nullable
private Path pathIfExists(String programArguments) {
Path candidate;
try {
candidate = Paths.get(programArguments);
} catch (InvalidPathException e) {
return null;
}
return fileExists.test(candidate) ? candidate : null;
}
private static String getServiceName(Path jarPath) {
String jarName = jarPath.getFileName().toString();
int dotIndex = jarName.lastIndexOf(".");

View File

@ -0,0 +1,83 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.instrumentation.resources;
import static java.util.logging.Level.WARNING;
import com.google.auto.service.AutoService;
import io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider;
import io.opentelemetry.semconv.ResourceAttributes;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Function;
import java.util.jar.Manifest;
import java.util.logging.Logger;
/**
* A {@link ResourceProvider} that will attempt to detect the <code>service.name</code> and <code>
* service.version</code> from META-INF/MANIFEST.MF.
*/
@AutoService(ResourceProvider.class)
public final class ManifestResourceProvider extends AttributeResourceProvider<Manifest> {
private static final Logger logger = Logger.getLogger(ManifestResourceProvider.class.getName());
@SuppressWarnings("unused") // SPI
public ManifestResourceProvider() {
this(new JarPathFinder(), ManifestResourceProvider::readManifest);
}
// Visible for testing
ManifestResourceProvider(
JarPathFinder jarPathFinder, Function<Path, Optional<Manifest>> manifestReader) {
super(
new AttributeProvider<Manifest>() {
@Override
public Optional<Manifest> readData() {
return jarPathFinder.getJarPath().flatMap(manifestReader);
}
@Override
public void registerAttributes(Builder<Manifest> builder) {
builder
.add(
ResourceAttributes.SERVICE_NAME,
manifest -> {
String serviceName =
manifest.getMainAttributes().getValue("Implementation-Title");
return Optional.ofNullable(serviceName);
})
.add(
ResourceAttributes.SERVICE_VERSION,
manifest -> {
String serviceVersion =
manifest.getMainAttributes().getValue("Implementation-Version");
return Optional.ofNullable(serviceVersion);
});
}
});
}
private static Optional<Manifest> readManifest(Path jarPath) {
try (InputStream s =
new URL(String.format("jar:%s!/META-INF/MANIFEST.MF", jarPath.toUri())).openStream()) {
Manifest manifest = new Manifest();
manifest.read(s);
return Optional.of(manifest);
} catch (Exception e) {
logger.log(WARNING, "Error reading manifest", e);
return Optional.empty();
}
}
@Override
public int order() {
// make it run later than ManifestResourceProvider and SpringBootServiceNameDetector
return 300;
}
}

View File

@ -16,6 +16,7 @@ import java.nio.file.Paths;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.ExtensionContext;
@ -27,26 +28,39 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
// todo split JarFileDetectorTest and JarServiceNameDetectorTest
class JarServiceNameDetectorTest {
@Mock ConfigProperties config;
@BeforeEach
void setUp() {
JarPathFinder.resetForTest();
}
@Test
void createResource_empty() {
JarServiceNameDetector serviceNameProvider =
new JarServiceNameDetector(
() -> new String[0], prop -> null, JarServiceNameDetectorTest::failPath);
String[] processArgs = new String[0];
Function<String, String> getProperty = prop -> null;
Predicate<Path> fileExists = JarServiceNameDetectorTest::failPath;
JarServiceNameDetector serviceNameProvider = getDetector(processArgs, getProperty, fileExists);
Resource resource = serviceNameProvider.createResource(config);
assertThat(resource.getAttributes()).isEmpty();
}
private static JarServiceNameDetector getDetector(
String[] processArgs, Function<String, String> getProperty, Predicate<Path> fileExists) {
return new JarServiceNameDetector(
new JarPathFinder(() -> processArgs, getProperty, fileExists));
}
@Test
void createResource_noJarFileInArgs() {
String[] args = new String[] {"-Dtest=42", "-Xmx666m", "-jar"};
JarServiceNameDetector serviceNameProvider =
new JarServiceNameDetector(() -> args, prop -> null, JarServiceNameDetectorTest::failPath);
getDetector(args, prop -> null, JarServiceNameDetectorTest::failPath);
Resource resource = serviceNameProvider.createResource(config);
@ -55,10 +69,8 @@ class JarServiceNameDetectorTest {
@Test
void createResource_processHandleJar() {
String path = Paths.get("path", "to", "app", "my-service.jar").toString();
String[] args = new String[] {"-Dtest=42", "-Xmx666m", "-jar", path, "abc", "def"};
JarServiceNameDetector serviceNameProvider =
new JarServiceNameDetector(() -> args, prop -> null, JarServiceNameDetectorTest::failPath);
getDetector(getArgs("my-service.jar"), prop -> null, JarServiceNameDetectorTest::failPath);
Resource resource = serviceNameProvider.createResource(config);
@ -69,10 +81,8 @@ class JarServiceNameDetectorTest {
@Test
void createResource_processHandleJarWithoutExtension() {
String path = Paths.get("path", "to", "app", "my-service.jar").toString();
String[] args = new String[] {"-Dtest=42", "-Xmx666m", "-jar", path};
JarServiceNameDetector serviceNameProvider =
new JarServiceNameDetector(() -> args, prop -> null, JarServiceNameDetectorTest::failPath);
getDetector(getArgs("my-service"), prop -> null, JarServiceNameDetectorTest::failPath);
Resource resource = serviceNameProvider.createResource(config);
@ -81,6 +91,11 @@ class JarServiceNameDetectorTest {
.containsEntry(ResourceAttributes.SERVICE_NAME, "my-service");
}
static String[] getArgs(String jarName) {
String path = Paths.get("path", "to", "app", jarName).toString();
return new String[] {"-Dtest=42", "-Xmx666m", "-jar", path, "abc", "def"};
}
@ParameterizedTest
@ArgumentsSource(SunCommandLineProvider.class)
void createResource_sunCommandLine(String commandLine, Path jarPath) {
@ -89,7 +104,7 @@ class JarServiceNameDetectorTest {
Predicate<Path> fileExists = jarPath::equals;
JarServiceNameDetector serviceNameProvider =
new JarServiceNameDetector(() -> new String[0], getProperty, fileExists);
getDetector(new String[0], getProperty, fileExists);
Resource resource = serviceNameProvider.createResource(config);
@ -107,7 +122,7 @@ class JarServiceNameDetectorTest {
Predicate<Path> fileExists = path -> false;
JarServiceNameDetector serviceNameProvider =
new JarServiceNameDetector(() -> new String[0], getProperty, fileExists);
getDetector(new String[0], getProperty, fileExists);
Resource resource = serviceNameProvider.createResource(config);
@ -128,7 +143,7 @@ class JarServiceNameDetectorTest {
}
}
private static boolean failPath(Path file) {
static boolean failPath(Path file) {
throw new AssertionError("Unexpected call to Files.isRegularFile()");
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.instrumentation.resources;
import static io.opentelemetry.semconv.ResourceAttributes.SERVICE_NAME;
import static io.opentelemetry.semconv.ResourceAttributes.SERVICE_VERSION;
import static org.assertj.core.api.Assertions.assertThat;
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;
import io.opentelemetry.sdk.resources.Resource;
import java.io.InputStream;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
class ManifestResourceProviderTest {
@BeforeEach
void setUp() {
JarPathFinder.resetForTest();
}
private static class TestCase {
private final String name;
private final String expectedName;
private final String expectedVersion;
private final InputStream input;
public TestCase(String name, String expectedName, String expectedVersion, InputStream input) {
this.name = name;
this.expectedName = expectedName;
this.expectedVersion = expectedVersion;
this.input = input;
}
}
@TestFactory
Collection<DynamicTest> createResource() {
ConfigProperties config = DefaultConfigProperties.createFromMap(Collections.emptyMap());
return Stream.of(
new TestCase("name ok", "demo", "0.0.1-SNAPSHOT", openClasspathResource("MANIFEST.MF")),
new TestCase("name - no resource", null, null, null),
new TestCase(
"name - empty resource", null, null, openClasspathResource("empty-MANIFEST.MF")))
.map(
t ->
DynamicTest.dynamicTest(
t.name,
() -> {
ManifestResourceProvider provider =
new ManifestResourceProvider(
new JarPathFinder(
() -> JarServiceNameDetectorTest.getArgs("app.jar"),
prop -> null,
JarServiceNameDetectorTest::failPath),
p -> {
try {
Manifest manifest = new Manifest();
manifest.read(t.input);
return Optional.of(manifest);
} catch (Exception e) {
return Optional.empty();
}
});
provider.shouldApply(config, Resource.getDefault());
Resource resource = provider.createResource(config);
assertThat(resource.getAttribute(SERVICE_NAME)).isEqualTo(t.expectedName);
assertThat(resource.getAttribute(SERVICE_VERSION))
.isEqualTo(t.expectedVersion);
}))
.collect(Collectors.toList());
}
private static InputStream openClasspathResource(String resource) {
return ManifestResourceProviderTest.class.getClassLoader().getResourceAsStream(resource);
}
}

View File

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Implementation-Title: demo
Implementation-Version: 0.0.1-SNAPSHOT

View File

@ -0,0 +1 @@
Manifest-Version: 1.0

View File

@ -5,7 +5,7 @@
package io.opentelemetry.smoketest
import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest
import io.opentelemetry.semconv.ResourceAttributes
import spock.lang.IgnoreIf
import spock.lang.Unroll
@ -14,7 +14,6 @@ import java.util.jar.Attributes
import java.util.jar.JarFile
import static io.opentelemetry.smoketest.TestContainerManager.useWindowsContainers
import static java.util.stream.Collectors.toSet
@IgnoreIf({ useWindowsContainers() })
class QuarkusSmokeTest extends SmokeTest {
@ -28,6 +27,11 @@ class QuarkusSmokeTest extends SmokeTest {
return new TargetWaitStrategy.Log(Duration.ofMinutes(1), ".*Listening on.*")
}
@Override
protected boolean getSetServiceName() {
return false
}
@Unroll
def "quarkus smoke test on JDK #jdk"(int jdk) {
setup:
@ -37,14 +41,16 @@ class QuarkusSmokeTest extends SmokeTest {
when:
client().get("/hello").aggregate().join()
Collection<ExportTraceServiceRequest> traces = waitForTraces()
TraceInspector traces = new TraceInspector(waitForTraces())
then:
countSpansByName(traces, 'GET /hello') == 1
then: "Expected span names"
traces.countSpansByName('GET /hello') == 1
[currentAgentVersion] as Set == findResourceAttribute(traces, "telemetry.distro.version")
.map { it.stringValue }
.collect(toSet())
and: "telemetry.distro.version is set"
traces.countFilteredResourceAttributes("telemetry.distro.version", currentAgentVersion) == 1
and: "service.name is detected from manifest"
traces.countFilteredResourceAttributes(ResourceAttributes.SERVICE_NAME.key, "smoke-test-quarkus-images") == 1
cleanup:
stopTarget()