feat: add SCA extension for runtime JAR library inventory#1007
feat: add SCA extension for runtime JAR library inventory#1007rejirajraghav wants to merge 1 commit intoelastic:mainfrom
Conversation
Adds a new `sca-extension` module that intercepts every JAR loaded by the JVM and emits one OTel log event per unique library to the `co.elastic.otel.sca` instrumentation scope. Key design: - `SCAExtension` implements both `AutoConfigurationCustomizerProvider` (registers config defaults before SDK init) and `AgentListener` (starts `JarCollectorService` after SDK is ready) - `JarCollectorService` uses a `ClassFileTransformer` that always returns null -- observes only, never modifies bytecode - Metadata extracted in priority order: pom.properties > MANIFEST.MF > filename pattern; SHA-256 and pURL (pkg:maven/...) computed per JAR - Bounded queue + daemon thread keeps class-loading threads unblocked - Token-bucket rate limiting (default 10 JARs/s, configurable) - Wired into agent via custom/build.gradle.kts as implementation dependency, identical to the inferred-spans pattern Config keys: elastic.otel.sca.enabled, elastic.otel.sca.skip_temp_jars, elastic.otel.sca.jars_per_second Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| // Skip temp JARs (e.g. JRuby, Groovy, or Spring Boot's exploded cache) | ||
| if (config.isSkipTempJars()) { | ||
| String normPath = normalise(jarPath); | ||
| if (normPath.startsWith(tmpDir) || normPath.contains("/tmp/")) { |
There was a problem hiding this comment.
🟡 Medium sca/JarCollectorService.java:344
shouldSkip() uses normPath.startsWith(tmpDir) without checking a path boundary, so a JAR at /tmpfoo/app.jar is incorrectly skipped when tmpDir is /tmp. This causes missing SCA events for any dependency installed under a path that merely shares the temp directory prefix. Consider adding a trailing slash to the prefix check so only actual temp directory contents are skipped.
- if (normPath.startsWith(tmpDir) || normPath.contains("/tmp/")) {
+ if (normPath.startsWith(tmpDir.endsWith("/") ? tmpDir : tmpDir + "/") || normPath.contains("/tmp/")) {🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java around line 344:
`shouldSkip()` uses `normPath.startsWith(tmpDir)` without checking a path boundary, so a JAR at `/tmpfoo/app.jar` is incorrectly skipped when `tmpDir` is `/tmp`. This causes missing SCA events for any dependency installed under a path that merely shares the temp directory prefix. Consider adding a trailing slash to the prefix check so only actual temp directory contents are skipped.
Evidence trail:
sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java:105 (tmpDir field declaration), line 113 (tmpDir initialization without trailing slash), line 344 (startsWith check without path boundary), lines 373-374 (normalise method shows no trailing slash is added). The code at REVIEWED_COMMIT clearly shows the path boundary issue where `/tmpfoo/app.jar`.startsWith(`/tmp`) would return true.
| if (artifactId.isEmpty()) { | ||
| Matcher m = FILENAME_VERSION_PATTERN.matcher(baseName); | ||
| if (m.matches()) { | ||
| artifactId = m.group(1); | ||
| if (version.isEmpty()) { | ||
| version = m.group(2); | ||
| } | ||
| } | ||
| // Note: when the filename does not match the version pattern we leave artifactId empty | ||
| // so that 'title' from the MANIFEST (if present) is preferred over the raw filename below. | ||
| } |
There was a problem hiding this comment.
🟡 Medium sca/JarMetadataExtractor.java:116
When Bundle-SymbolicName is present in the manifest but Implementation-Version is missing, artifactId is populated from the bundle name (line 105) but version remains empty. The filename-based version parser at line 116 is then skipped because artifactId is non-empty, so jars like foo-1.2.3.jar with a manifest containing only Bundle-SymbolicName: foo return with an empty version despite the filename containing a valid version. Consider removing the artifactId.isEmpty() guard at line 116 so filename version extraction runs regardless of whether the artifactId was already determined.
- if (artifactId.isEmpty()) {
+ if (artifactId.isEmpty() || version.isEmpty()) {
Matcher m = FILENAME_VERSION_PATTERN.matcher(baseName);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java around lines 116-126:
When `Bundle-SymbolicName` is present in the manifest but `Implementation-Version` is missing, `artifactId` is populated from the bundle name (line 105) but `version` remains empty. The filename-based version parser at line 116 is then skipped because `artifactId` is non-empty, so jars like `foo-1.2.3.jar` with a manifest containing only `Bundle-SymbolicName: foo` return with an empty version despite the filename containing a valid version. Consider removing the `artifactId.isEmpty()` guard at line 116 so filename version extraction runs regardless of whether the artifactId was already determined.
Evidence trail:
sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java lines 95-126 at REVIEWED_COMMIT: Lines 101-106 show artifactId extraction from Bundle-SymbolicName when artifactId.isEmpty(). Line 116 shows `if (artifactId.isEmpty())` guard. Lines 117-122 show filename pattern matching and version extraction (line 120) only happens inside this guard. This confirms the defect: version extraction from filename is skipped when artifactId was already populated from Bundle-SymbolicName.
| // jar:file:/path/to/outer.jar!/ — nested JAR (Spring Boot, etc.) | ||
| if ("jar".equals(location.getProtocol())) { | ||
| String path = location.getPath(); // file:/path/to/outer.jar!/ | ||
| int bang = path.indexOf('!'); | ||
| if (bang >= 0) { | ||
| path = path.substring(0, bang); | ||
| } | ||
| return new File(new URI(path)).getAbsolutePath(); | ||
| } |
There was a problem hiding this comment.
🟠 High sca/JarCollectorService.java:218
locationToJarPath() truncates at the first ! for nested JAR URLs like jar:file:/app.jar!/BOOT-INF/lib/log4j-api-2.22.jar!/, returning /app.jar instead of the nested path. Since seenJarPaths deduplicates on this outer path, all nested libraries from a Spring Boot fat JAR collapse into one record and most dependencies are never reported. Consider preserving the full nested JAR path up to the last !.
- if ("jar".equals(location.getProtocol())) {
- String path = location.getPath(); // file:/path/to/outer.jar!/
- int bang = path.indexOf('!');
- if (bang >= 0) {
- path = path.substring(0, bang);
- }
- return new File(new URI(path)).getAbsolutePath();
- }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java around lines 218-226:
`locationToJarPath()` truncates at the first `!` for nested JAR URLs like `jar:file:/app.jar!/BOOT-INF/lib/log4j-api-2.22.jar!/`, returning `/app.jar` instead of the nested path. Since `seenJarPaths` deduplicates on this outer path, all nested libraries from a Spring Boot fat JAR collapse into one record and most dependencies are never reported. Consider preserving the full nested JAR path up to the last `!`.
Evidence trail:
sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java lines 218-227 (REVIEWED_COMMIT): `locationToJarPath()` uses `path.indexOf('!')` and `path.substring(0, bang)` to truncate at first `!`. Lines 195-198: `seenJarPaths.add(jarPath)` deduplicates on the returned path, causing early return for already-seen paths.
| private static String resolveAgentJarPath() { | ||
| String path = System.getProperty("elastic.otel.agent.jar.path"); | ||
| if (path != null) { | ||
| return normalise(path); | ||
| } | ||
| // Fallback: parse -javaagent flag from the JVM command line | ||
| String cmd = System.getProperty("sun.java.command", ""); | ||
| for (String token : cmd.split("\\s+")) { | ||
| if (token.contains("elastic-otel-javaagent") || token.contains("opentelemetry-javaagent")) { | ||
| return normalise(token); | ||
| } | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
🟢 Low sca/JarCollectorService.java:358
resolveAgentJarPath() normalizes the returned path to / separators, but shouldSkip() compares it against paths from File.getAbsolutePath() which use platform separators. On Windows, elastic.otel.agent.jar.path=C:\agent\agent.jar becomes C:/agent/agent.jar while the runtime jarPath stays C:\agent\agent.jar, so the equality check fails and the agent JAR is not excluded.
private static String resolveAgentJarPath() {
String path = System.getProperty("elastic.otel.agent.jar.path");
if (path != null) {
- return normalise(path);
+ return new File(path).getAbsolutePath();
}
// Fallback: parse -javaagent flag from the JVM command line
String cmd = System.getProperty("sun.java.command", "");
for (String token : cmd.split("\\s+")) {
if (token.contains("elastic-otel-javaagent") || token.contains("opentelemetry-javaagent")) {
- return normalise(token);
+ return new File(token).getAbsolutePath();
}
}
return null;
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java around lines 358-371:
`resolveAgentJarPath()` normalizes the returned path to `/` separators, but `shouldSkip()` compares it against paths from `File.getAbsolutePath()` which use platform separators. On Windows, `elastic.otel.agent.jar.path=C:\agent\agent.jar` becomes `C:/agent/agent.jar` while the runtime `jarPath` stays `C:\agent\agent.jar`, so the equality check fails and the agent JAR is not excluded.
Evidence trail:
JarCollectorService.java lines 358-379: `resolveAgentJarPath()` uses `normalise(path)` which replaces `\` with `/`.
JarCollectorService.java line 216, 225: `locationToJarPath()` uses `new File(...).getAbsolutePath()` which returns platform-native separators.
JarCollectorService.java line 338: `shouldSkip()` compares `agentJarPath.equals(jarPath)` - normalized path vs native path.
Commit: REVIEWED_COMMIT
| static Properties findPomProperties(JarFile jar) throws IOException { | ||
| Enumeration<JarEntry> entries = jar.entries(); | ||
| while (entries.hasMoreElements()) { | ||
| JarEntry entry = entries.nextElement(); | ||
| if (entry.isDirectory()) { | ||
| continue; | ||
| } | ||
| String name = entry.getName(); | ||
| // META-INF/maven/{groupId}/{artifactId}/pom.properties | ||
| if (name.startsWith("META-INF/maven/") && name.endsWith("/pom.properties")) { | ||
| Properties props = new Properties(); | ||
| try (InputStream in = jar.getInputStream(entry)) { | ||
| props.load(in); | ||
| } | ||
| return props; | ||
| } | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
🟡 Medium sca/JarMetadataExtractor.java:149
An IOException while reading pom.properties escapes findPomProperties and causes extract() to return null, dropping the entire JAR even though MANIFEST.MF or filename parsing could still recover metadata. Consider catching the exception inside findPomProperties and returning null so the fallback sources in extract() are tried.
static Properties findPomProperties(JarFile jar) throws IOException {
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
if (entry.isDirectory()) {
continue;
}
String name = entry.getName();
// META-INF/maven/{groupId}/{artifactId}/pom.properties
if (name.startsWith("META-INF/maven/") && name.endsWith("/pom.properties")) {
Properties props = new Properties();
try (InputStream in = jar.getInputStream(entry)) {
props.load(in);
}
return props;
}
}
return null;
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java around lines 149-167:
An `IOException` while reading `pom.properties` escapes `findPomProperties` and causes `extract()` to return `null`, dropping the entire JAR even though `MANIFEST.MF` or filename parsing could still recover metadata. Consider catching the exception inside `findPomProperties` and returning `null` so the fallback sources in `extract()` are tried.
Evidence trail:
sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java:
- Line 138: `static Properties findPomProperties(JarFile jar) throws IOException`
- Lines 149-151: IOException can be thrown by `props.load(in)`
- Line 73: `Properties pomProps = findPomProperties(jar);` inside try block
- Lines 95-98: catch block catches IOException and returns null immediately
- Lines 80-93: MANIFEST.MF fallback is inside the same try block
- Lines 102-112: filename fallback is after try-catch but unreachable due to early return
- Lines 35-40: class Javadoc documents three-source fallback design
| private final LinkedBlockingQueue<PendingJar> pendingJars = | ||
| new LinkedBlockingQueue<>(QUEUE_CAPACITY); | ||
|
|
||
| private final AtomicBoolean started = new AtomicBoolean(false); | ||
| private final AtomicBoolean stopped = new AtomicBoolean(false); | ||
|
|
||
| /** Names/patterns to identify JARs that should never be reported. */ | ||
| private final String agentJarPath; | ||
|
|
||
| private final String tmpDir; | ||
|
|
||
| JarCollectorService( | ||
| OpenTelemetrySdk openTelemetry, Instrumentation instrumentation, SCAConfiguration config) { | ||
| this.openTelemetry = openTelemetry; | ||
| this.instrumentation = instrumentation; | ||
| this.config = config; | ||
| this.agentJarPath = resolveAgentJarPath(); | ||
| this.tmpDir = normalise(System.getProperty("java.io.tmpdir", "/tmp")); | ||
| } | ||
|
|
||
| // ---- Lifecycle --------------------------------------------------------- | ||
|
|
||
| void start() { | ||
| if (!started.compareAndSet(false, true)) { | ||
| return; | ||
| } | ||
|
|
||
| // Register transformer — returns null always, observes only | ||
| instrumentation.addTransformer(this, /* canRetransform= */ false); | ||
|
|
||
| // Back-fill classes already loaded before our transformer registered | ||
| scanAlreadyLoadedClasses(); | ||
|
|
There was a problem hiding this comment.
🟠 High sca/JarCollectorService.java:96
scanAlreadyLoadedClasses() runs in start() before the worker thread begins consuming from pendingJars, so any JVM with more than 500 loaded JARs permanently drops metadata for the excess JARs. Those classes are already loaded, so there will be no retry opportunity later.
worker.start();
// Drain remaining queue on JVM shutdown before the OTLP exporter shuts down🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java around lines 96-128:
`scanAlreadyLoadedClasses()` runs in `start()` before the worker thread begins consuming from `pendingJars`, so any JVM with more than 500 loaded JARs permanently drops metadata for the excess JARs. Those classes are already loaded, so there will be no retry opportunity later.
Evidence trail:
sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java:
- Line 73: `QUEUE_CAPACITY = 500`
- Lines 105-120: `start()` method - `scanAlreadyLoadedClasses()` called at line 114, worker thread started at line 120
- Lines 170-174: Queue full handling removes from seenJarPaths with comment "will retry on next class load"
- Lines 196-208: `scanAlreadyLoadedClasses()` iterates already loaded classes
| static String buildPurl(String groupId, String artifactId, String version) { | ||
| if (artifactId.isEmpty()) { | ||
| return ""; | ||
| } | ||
| StringBuilder sb = new StringBuilder("pkg:maven/"); | ||
| if (!groupId.isEmpty()) { | ||
| sb.append(groupId).append('/'); | ||
| } | ||
| sb.append(artifactId); | ||
| if (!version.isEmpty()) { | ||
| sb.append('@').append(version); | ||
| } | ||
| return sb.toString(); | ||
| } |
There was a problem hiding this comment.
🟡 Medium sca/JarMetadataExtractor.java:173
buildPurl() returns pkg:maven/{artifactId}@{version} when groupId is empty, producing malformed PURLs like pkg:maven/guava@32.1.3 that omit the required groupId segment. Consider returning "" when groupId is unknown to avoid emitting invalid Maven PURLs.
static String buildPurl(String groupId, String artifactId, String version) {
- if (artifactId.isEmpty()) {
+ if (groupId.isEmpty() || artifactId.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder("pkg:maven/");
- if (!groupId.isEmpty()) {
- sb.append(groupId).append('/');
- }
- sb.append(artifactId);
+ sb.append(groupId).append('/').append(artifactId);
if (!version.isEmpty()) {
sb.append('@').append(version);
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java around lines 173-186:
`buildPurl()` returns `pkg:maven/{artifactId}@{version}` when `groupId` is empty, producing malformed PURLs like `pkg:maven/guava@32.1.3` that omit the required groupId segment. Consider returning `""` when `groupId` is unknown to avoid emitting invalid Maven PURLs.
Evidence trail:
1. JarMetadataExtractor.java lines 173-186 (REVIEWED_COMMIT): buildPurl() method skips groupId when empty, producing `pkg:maven/artifactId@version`
2. JarMetadataExtractorTest.java lines 128-130 (REVIEWED_COMMIT): Test confirms expected output is `pkg:maven/my-lib@1.0` when groupId is empty
3. https://github.com/package-url/purl-spec/blob/main/docs/types.md - Maven PURL type definition
4. https://github.com/DependencyTrack/dependency-track/issues/2694 - Confirms namespace is required for Maven PURLs
| * | ||
| * @return loaded {@link Properties}, or {@code null} if not found | ||
| */ | ||
| static Properties findPomProperties(JarFile jar) throws IOException { |
There was a problem hiding this comment.
🟡 Medium sca/JarMetadataExtractor.java:149
findPomProperties() returns the first META-INF/maven/**/pom.properties it encounters, but fat JARs contain multiple such files for bundled dependencies. When scanning a fat JAR, the method may return the metadata of an embedded dependency instead of the outer artifact, producing incorrect groupId, artifactId, version, and PURL for the JAR being processed. Consider checking whether the JAR is a fat JAR (e.g., by detecting BOOT-INF/ or multiple pom.properties entries) and handling it appropriately, or document that fat JARs are not supported.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java around line 149:
`findPomProperties()` returns the first `META-INF/maven/**/pom.properties` it encounters, but fat JARs contain multiple such files for bundled dependencies. When scanning a fat JAR, the method may return the metadata of an embedded dependency instead of the outer artifact, producing incorrect `groupId`, `artifactId`, `version`, and PURL for the JAR being processed. Consider checking whether the JAR is a fat JAR (e.g., by detecting `BOOT-INF/` or multiple `pom.properties` entries) and handling it appropriately, or document that fat JARs are not supported.
Evidence trail:
sca-extension/src/main/java/co/elastic/otel/sca/JarMetadataExtractor.java lines 145-167 at REVIEWED_COMMIT: method `findPomProperties()` returns first matching `META-INF/maven/**/pom.properties` entry; git_grep for 'BOOT-INF|maven-shade|fat.*jar|uber.*jar' in sca-extension/** returned no results, confirming no existing fat JAR handling
| */ | ||
| @Override | ||
| public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetrySdk) { | ||
| SCAConfiguration config = SCAConfiguration.get(); |
There was a problem hiding this comment.
🟠 High sca/SCAExtension.java:100
afterAgent() constructs SCAConfiguration.get() from System.getProperty/System.getenv only, ignoring any elastic.otel.sca.* values resolved through the OTel autoconfigure pipeline (SDK config file, ConfigProperties customizers, etc.). A user setting elastic.otel.sca.enabled=false in the OTel config file is silently ignored and JarCollectorService starts anyway.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/SCAExtension.java around line 100:
`afterAgent()` constructs `SCAConfiguration.get()` from `System.getProperty`/`System.getenv` only, ignoring any `elastic.otel.sca.*` values resolved through the OTel autoconfigure pipeline (SDK config file, `ConfigProperties` customizers, etc.). A user setting `elastic.otel.sca.enabled=false` in the OTel config file is silently ignored and `JarCollectorService` starts anyway.
Evidence trail:
- `sca-extension/src/main/java/co/elastic/otel/sca/SCAConfiguration.java` lines 51-55 (`get()` method), lines 67-75 (`readBoolean()` method) - only reads from `System.getProperty()` and `System.getenv()`
- `sca-extension/src/main/java/co/elastic/otel/sca/SCAExtension.java` line 100 (`afterAgent()` calls `SCAConfiguration.get()`)
- `sca-extension/src/main/java/co/elastic/otel/sca/SCAExtension.java` lines 62-71 (`customize()` registers properties with OTel's config pipeline)
- OTel Java Agent Configuration docs at https://opentelemetry.io/docs/zero-code/java/agent/configuration/ confirming config file as a valid configuration source
| private void processJar(PendingJar pending, Logger otelLogger) { | ||
| try { | ||
| JarMetadata meta = JarMetadataExtractor.extract(pending.jarPath, pending.classloaderName); | ||
| if (meta != null) { | ||
| emitLogRecord(meta, otelLogger); | ||
| } | ||
| } catch (Exception e) { | ||
| log.log(Level.FINE, "SCA: error processing JAR: " + pending.jarPath, e); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Medium sca/JarCollectorService.java:295
When JarMetadataExtractor.extract() returns null (e.g., on IOException) or emitLogRecord() throws, processJar() logs and returns without removing pending.jarPath from seenJarPaths. Since the path was already added in enqueueFromProtectionDomain(), future class loads from the same JAR are permanently ignored and the library is never reported. On failure, remove pending.jarPath from seenJarPaths so a later class load can retry.
- private void processJar(PendingJar pending, Logger otelLogger) {
- try {
- JarMetadata meta = JarMetadataExtractor.extract(pending.jarPath, pending.classloaderName);
- if (meta != null) {
- emitLogRecord(meta, otelLogger);
- }
- } catch (Exception e) {
- log.log(Level.FINE, "SCA: error processing JAR: " + pending.jarPath, e);
- }
- }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file sca-extension/src/main/java/co/elastic/otel/sca/JarCollectorService.java around lines 295-304:
When `JarMetadataExtractor.extract()` returns `null` (e.g., on `IOException`) or `emitLogRecord()` throws, `processJar()` logs and returns without removing `pending.jarPath` from `seenJarPaths`. Since the path was already added in `enqueueFromProtectionDomain()`, future class loads from the same JAR are permanently ignored and the library is never reported. On failure, remove `pending.jarPath` from `seenJarPaths` so a later class load can retry.
Evidence trail:
JarCollectorService.java lines 169, 174-176 (seenJarPaths.add and conditional removal on queue full), lines 286-296 (processJar method with no seenJarPaths removal on failure). JarMetadataExtractor.java lines 64-67 (returns null if file doesn't exist), lines 100-102 (returns null on IOException).
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughThis pull request introduces a new SCA (Software Component Analysis) extension module to the Elastic OpenTelemetry Java agent. The extension intercepts class loading to discover JAR files in the runtime, extracts metadata (name, version, group ID, PURL, SHA-256 hash) from various sources (pom.properties, MANIFEST.MF, filename patterns), deduplicates entries, and emits them as OpenTelemetry log records. Configuration is provided via system properties and environment variables. The module is integrated into the build system and registered as a service provider for OpenTelemetry's autoconfig and agent listener mechanisms, with comprehensive test coverage for the metadata extraction logic. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Comment Tip You can make CodeRabbit's review stricter and more nitpicky using the `assertive` profile, if that's what you prefer.Change the |
Adds a new
sca-extensionmodule that intercepts every JAR loaded by the JVM and emits one OTel log event per unique library to theco.elastic.otel.scainstrumentation scope.Key design:
SCAExtensionimplements bothAutoConfigurationCustomizerProvider(registers config defaults before SDK init) andAgentListener(startsJarCollectorServiceafter SDK is ready)JarCollectorServiceuses aClassFileTransformerthat always returns null -- observes only, never modifies bytecodefilename pattern; SHA-256 and pURL (pkg:maven/...) computed per JAR
Config keys: elastic.otel.sca.enabled, elastic.otel.sca.skip_temp_jars, elastic.otel.sca.jars_per_second