diff --git a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/ExtensionFinder.java b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/ExtensionFinder.java index 1a19535d218..e44af2805e5 100644 --- a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/ExtensionFinder.java +++ b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/ExtensionFinder.java @@ -3,6 +3,7 @@ import static datadog.opentelemetry.tooling.OtelExtensionHandler.OPENTELEMETRY; import static datadog.trace.agent.tooling.ExtensionHandler.DATADOG; +import datadog.trace.api.telemetry.OtelSpiCollector; import de.thetaphi.forbiddenapis.SuppressForbidden; import java.io.File; import java.io.FileNotFoundException; @@ -13,6 +14,7 @@ import java.net.URLConnection; import java.net.URLStreamHandler; import java.util.ArrayList; +import java.util.Enumeration; import java.util.List; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -26,6 +28,11 @@ public final class ExtensionFinder { private static final ExtensionHandler[] handlers = {OPENTELEMETRY, DATADOG}; + private static final String EXTENSIONS_PATH_SOURCE = "extensions_path"; + + private static final String SERVICES_PREFIX = "META-INF/services/"; + private static final String OTEL_NAMESPACE = "io.opentelemetry."; + /** * Discovers extensions on the configured path and creates a classloader for each extension. * Registers the combined classloader with {@link Utils#setExtendedClassLoader(ClassLoader)}. @@ -40,6 +47,7 @@ public static boolean findExtensions(String extensionsPath, Class... extensio String[] descriptors = descriptors(extensionTypes); for (JarFile jar : findExtensionJars(extensionsPath)) { + recordOtelSpiTelemetry(jar); URL extensionURL = findExtensionURL(jar, descriptors); if (null != extensionURL) { log.debug("Found extension jar {}", jar.getName()); @@ -60,6 +68,24 @@ public static boolean findExtensions(String extensionsPath, Class... extensio return !classLoaders.isEmpty(); } + /** + * Reports telemetry for any OpenTelemetry SPI service descriptors present in the jar — any entry + * under {@code META-INF/services/} whose name lives in the {@code io.opentelemetry.*} namespace. + * The jar's existing handle is reused; no new file resources are opened or held. + */ + static void recordOtelSpiTelemetry(JarFile jar) { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + if (name.startsWith(SERVICES_PREFIX)) { + String fqn = name.substring(SERVICES_PREFIX.length()); + if (fqn.startsWith(OTEL_NAMESPACE)) { + OtelSpiCollector.getInstance().recordSpiDetected(fqn, EXTENSIONS_PATH_SOURCE); + } + } + } + } + /** Closes jar resources from the extension path which did not contain any extensions. */ private static void close(List unusedJars) { for (JarFile jar : unusedJars) { diff --git a/dd-java-agent/agent-installer/src/test/java/datadog/trace/agent/tooling/ExtensionFinderTest.java b/dd-java-agent/agent-installer/src/test/java/datadog/trace/agent/tooling/ExtensionFinderTest.java new file mode 100644 index 00000000000..a865a998d4c --- /dev/null +++ b/dd-java-agent/agent-installer/src/test/java/datadog/trace/agent/tooling/ExtensionFinderTest.java @@ -0,0 +1,193 @@ +package datadog.trace.agent.tooling; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.telemetry.OtelSpiCollector; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class ExtensionFinderTest { + + private static final String AUTOCONFIGURE_PROPAGATOR = + "io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider"; + private static final String AUTOCONFIGURE_RESOURCE = + "io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider"; + private static final String AUTOCONFIGURE_SAMPLER = + "io.opentelemetry.sdk.autoconfigure.spi.ConfigurableSamplerProvider"; + private static final String AUTOCONFIGURE_EXPORTER = + "io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider"; + private static final String JAVAAGENT_INSTRUMENTATION_MODULE = + "io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule"; + private static final String JAVAAGENT_AGENT_LISTENER = + "io.opentelemetry.javaagent.extension.AgentListener"; + private static final String SHADED_AUTOCONFIGURE_SAMPLER = + "io.opentelemetry.javaagent.shaded.io.opentelemetry.sdk.autoconfigure.spi.ConfigurableSamplerProvider"; + + private final OtelSpiCollector collector = OtelSpiCollector.getInstance(); + + @BeforeEach + public void clearCollector() { + collector.drain(); + } + + @Test + public void singleOtelSpiIsReported(@TempDir Path tempDir) throws IOException { + Path jarPath = buildJar(tempDir, "ext.jar", AUTOCONFIGURE_PROPAGATOR); + + try (JarFile jar = new JarFile(jarPath.toFile(), false)) { + ExtensionFinder.recordOtelSpiTelemetry(jar); + } + + Collection drained = collector.drain(); + assertEquals(1, drained.size()); + OtelSpiCollector.OtelSpiMetric metric = drained.iterator().next(); + assertEquals("otel.spi.detected", metric.metricName); + assertTrue(metric.tags.contains("spi_class:" + AUTOCONFIGURE_PROPAGATOR)); + assertTrue(metric.tags.contains("source:extensions_path")); + } + + @Test + public void allFourAutoconfigureSpisAreReported(@TempDir Path tempDir) throws IOException { + Path jarPath = + buildJar( + tempDir, + "ext.jar", + AUTOCONFIGURE_PROPAGATOR, + AUTOCONFIGURE_RESOURCE, + AUTOCONFIGURE_SAMPLER, + AUTOCONFIGURE_EXPORTER); + + try (JarFile jar = new JarFile(jarPath.toFile(), false)) { + ExtensionFinder.recordOtelSpiTelemetry(jar); + } + + assertEquals( + new HashSet<>( + java.util.Arrays.asList( + AUTOCONFIGURE_PROPAGATOR, + AUTOCONFIGURE_RESOURCE, + AUTOCONFIGURE_SAMPLER, + AUTOCONFIGURE_EXPORTER)), + reportedFqns(collector.drain())); + } + + @Test + public void javaagentExtensionSpisAreReported(@TempDir Path tempDir) throws IOException { + Path jarPath = + buildJar(tempDir, "ext.jar", JAVAAGENT_INSTRUMENTATION_MODULE, JAVAAGENT_AGENT_LISTENER); + + try (JarFile jar = new JarFile(jarPath.toFile(), false)) { + ExtensionFinder.recordOtelSpiTelemetry(jar); + } + + assertEquals( + new HashSet<>( + java.util.Arrays.asList(JAVAAGENT_INSTRUMENTATION_MODULE, JAVAAGENT_AGENT_LISTENER)), + reportedFqns(collector.drain())); + } + + @Test + public void shadedJavaagentSpiIsReported(@TempDir Path tempDir) throws IOException { + Path jarPath = buildJar(tempDir, "ext.jar", SHADED_AUTOCONFIGURE_SAMPLER); + + try (JarFile jar = new JarFile(jarPath.toFile(), false)) { + ExtensionFinder.recordOtelSpiTelemetry(jar); + } + + Collection drained = collector.drain(); + assertEquals(1, drained.size()); + assertTrue( + drained.iterator().next().tags.contains("spi_class:" + SHADED_AUTOCONFIGURE_SAMPLER)); + } + + @Test + public void nonOtelSpiIsIgnored(@TempDir Path tempDir) throws IOException { + Path jarPath = + buildJar( + tempDir, + "ext.jar", + "com.example.MyService", + "org.springframework.context.ApplicationContextInitializer", + "java.sql.Driver"); + + try (JarFile jar = new JarFile(jarPath.toFile(), false)) { + ExtensionFinder.recordOtelSpiTelemetry(jar); + } + + assertEquals(0, collector.drain().size()); + } + + @Test + public void jarWithoutAnyServiceDescriptorsEmitsNothing(@TempDir Path tempDir) + throws IOException { + Path jarPath = tempDir.resolve("empty.jar"); + try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jarPath))) { + jos.putNextEntry(new JarEntry("README.txt")); + jos.write("not an extension".getBytes()); + jos.closeEntry(); + } + + try (JarFile jar = new JarFile(jarPath.toFile(), false)) { + ExtensionFinder.recordOtelSpiTelemetry(jar); + } + + assertEquals(0, collector.drain().size()); + } + + @Test + public void mixedOtelAndNonOtelReportsOnlyOtel(@TempDir Path tempDir) throws IOException { + Path jarPath = + buildJar( + tempDir, + "ext.jar", + AUTOCONFIGURE_PROPAGATOR, + "com.example.MyService", + JAVAAGENT_AGENT_LISTENER, + "java.sql.Driver"); + + try (JarFile jar = new JarFile(jarPath.toFile(), false)) { + ExtensionFinder.recordOtelSpiTelemetry(jar); + } + + assertEquals( + new HashSet<>(java.util.Arrays.asList(AUTOCONFIGURE_PROPAGATOR, JAVAAGENT_AGENT_LISTENER)), + reportedFqns(collector.drain())); + } + + private static Set reportedFqns(Collection drained) { + Set fqns = new HashSet<>(); + for (OtelSpiCollector.OtelSpiMetric metric : drained) { + for (String tag : metric.tags) { + if (tag.startsWith("spi_class:")) { + fqns.add(tag.substring("spi_class:".length())); + } + } + } + return fqns; + } + + /** Builds a jar with empty {@code META-INF/services/} entries for each given FQN. */ + private static Path buildJar(Path dir, String name, String... serviceFqns) throws IOException { + Path jarPath = dir.resolve(name); + try (OutputStream out = Files.newOutputStream(jarPath); + JarOutputStream jos = new JarOutputStream(out)) { + for (String fqn : serviceFqns) { + jos.putNextEntry(new JarEntry("META-INF/services/" + fqn)); + jos.closeEntry(); + } + } + return jarPath; + } +}