From 7689228d47d9a0d808b8cbddfd498fa09fac95f4 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Sat, 28 Jun 2025 00:22:55 -0400 Subject: [PATCH] Filter metrics by scope (#14136) --- docs/instrumentation-list.yaml | 33 ---- .../docs/InstrumentationAnalyzer.java | 28 +-- .../docs/internal/EmittedMetrics.java | 60 +++++- .../docs/parsers/EmittedMetricsParser.java | 106 +++++++++-- .../docs/parsers/MetricParser.java | 180 ++++++++++++++++++ .../docs/utils/YamlHelper.java | 4 +- .../parsers/EmittedMetricsParserTest.java | 128 +++++++++++++ .../docs/parsers/MetricParserTest.java | 145 +++++++------- .../testing/AgentTestRunner.java | 2 +- .../testing/InstrumentationTestRunner.java | 10 +- .../testing/LibraryTestRunner.java | 2 +- .../testing/internal/MetaDataCollector.java | 60 +++--- 12 files changed, 572 insertions(+), 186 deletions(-) create mode 100644 instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/MetricParser.java create mode 100644 instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParserTest.java diff --git a/docs/instrumentation-list.yaml b/docs/instrumentation-list.yaml index 2531a97646..b7291ea049 100644 --- a/docs/instrumentation-list.yaml +++ b/docs/instrumentation-list.yaml @@ -2836,24 +2836,6 @@ libraries: target_versions: javaagent: - io.projectreactor.netty:reactor-netty:[0.8.2.RELEASE,1.0.0) - telemetry: - - when: default - metrics: - - name: http.client.request.duration - description: Duration of HTTP client requests. - type: HISTOGRAM - unit: s - attributes: - - name: http.request.method - type: STRING - - name: http.response.status_code - type: LONG - - name: network.protocol.version - type: STRING - - name: server.address - type: STRING - - name: server.port - type: LONG - name: reactor-netty-1.0 source_path: instrumentation/reactor/reactor-netty/reactor-netty-1.0 scope: @@ -3214,21 +3196,6 @@ libraries: type: STRING - name: server.port type: LONG - - name: http.server.request.duration - description: Duration of HTTP server requests. - type: HISTOGRAM - unit: s - attributes: - - name: http.request.method - type: STRING - - name: http.response.status_code - type: LONG - - name: http.route - type: STRING - - name: network.protocol.version - type: STRING - - name: url.scheme - type: STRING - name: spring-webflux-5.3 source_path: instrumentation/spring/spring-webflux/spring-webflux-5.3 scope: diff --git a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/InstrumentationAnalyzer.java b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/InstrumentationAnalyzer.java index 2e7bbc9fd5..38cee23f1b 100644 --- a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/InstrumentationAnalyzer.java +++ b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/InstrumentationAnalyzer.java @@ -7,19 +7,17 @@ package io.opentelemetry.instrumentation.docs; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.exc.ValueInstantiationException; -import io.opentelemetry.instrumentation.docs.internal.EmittedMetrics; import io.opentelemetry.instrumentation.docs.internal.InstrumentationMetaData; import io.opentelemetry.instrumentation.docs.internal.InstrumentationModule; import io.opentelemetry.instrumentation.docs.internal.InstrumentationType; -import io.opentelemetry.instrumentation.docs.parsers.EmittedMetricsParser; import io.opentelemetry.instrumentation.docs.parsers.GradleParser; +import io.opentelemetry.instrumentation.docs.parsers.MetricParser; import io.opentelemetry.instrumentation.docs.parsers.ModuleParser; import io.opentelemetry.instrumentation.docs.parsers.SpanParser; import io.opentelemetry.instrumentation.docs.utils.FileManager; import io.opentelemetry.instrumentation.docs.utils.InstrumentationPath; import io.opentelemetry.instrumentation.docs.utils.YamlHelper; import java.io.IOException; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -65,7 +63,7 @@ class InstrumentationAnalyzer { } module.setTargetVersions(getVersionInformation(module)); - module.setMetrics(MetricsProcessor.getMetrics(module, fileManager)); + module.setMetrics(MetricParser.getMetrics(module, fileManager)); module.setSpans(SpanParser.getSpans(module, fileManager)); } @@ -89,26 +87,4 @@ class InstrumentationAnalyzer { List gradleFiles = fileManager.findBuildGradleFiles(module.getSrcPath()); return GradleParser.extractVersions(gradleFiles, module); } - - /** Handles processing of metrics data for instrumentation modules. */ - static class MetricsProcessor { - - public static Map> getMetrics( - InstrumentationModule module, FileManager fileManager) { - Map metrics = - EmittedMetricsParser.getMetricsFromFiles(fileManager.rootDir(), module.getSrcPath()); - - Map> result = new HashMap<>(); - metrics.entrySet().stream() - .filter(MetricsProcessor::hasValidMetrics) - .forEach(entry -> result.put(entry.getKey(), entry.getValue().getMetrics())); - return result; - } - - private static boolean hasValidMetrics(Map.Entry entry) { - return entry.getValue() != null && entry.getValue().getMetrics() != null; - } - - private MetricsProcessor() {} - } } diff --git a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/internal/EmittedMetrics.java b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/internal/EmittedMetrics.java index 8e1d055d36..c00f64898f 100644 --- a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/internal/EmittedMetrics.java +++ b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/internal/EmittedMetrics.java @@ -5,6 +5,9 @@ package io.opentelemetry.instrumentation.docs.internal; +import static java.util.Collections.emptyList; + +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.ArrayList; import java.util.List; @@ -16,16 +19,18 @@ import java.util.List; public class EmittedMetrics { // Condition in which the metrics are emitted (ex: default, or configuration option names). private String when; - private List metrics; + + @JsonProperty("metrics_by_scope") + private List metricsByScope; public EmittedMetrics() { this.when = ""; - this.metrics = new ArrayList<>(); + this.metricsByScope = emptyList(); } - public EmittedMetrics(String when, List metrics) { - this.when = ""; - this.metrics = metrics; + public EmittedMetrics(String when, List metricsByScope) { + this.when = when; + this.metricsByScope = metricsByScope; } public String getWhen() { @@ -36,12 +41,49 @@ public class EmittedMetrics { this.when = when; } - public List getMetrics() { - return metrics; + @JsonProperty("metrics_by_scope") + public List getMetricsByScope() { + return metricsByScope; } - public void setMetrics(List metrics) { - this.metrics = metrics; + @JsonProperty("metrics_by_scope") + public void setMetricsByScope(List metricsByScope) { + this.metricsByScope = metricsByScope; + } + + /** + * This class is internal and is hence not for public use. Its APIs are unstable and can change at + * any time. + */ + public static class MetricsByScope { + private String scope; + private List metrics; + + public MetricsByScope(String scope, List metrics) { + this.scope = scope; + this.metrics = metrics; + } + + public MetricsByScope() { + this.scope = ""; + this.metrics = new ArrayList<>(); + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public List getMetrics() { + return metrics; + } + + public void setMetrics(List metrics) { + this.metrics = metrics; + } } /** diff --git a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParser.java b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParser.java index 6374f89540..7a5d85ffbf 100644 --- a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParser.java +++ b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParser.java @@ -5,6 +5,7 @@ package io.opentelemetry.instrumentation.docs.parsers; +import com.fasterxml.jackson.core.JsonProcessingException; import io.opentelemetry.instrumentation.docs.internal.EmittedMetrics; import io.opentelemetry.instrumentation.docs.utils.FileManager; import io.opentelemetry.instrumentation.docs.utils.YamlHelper; @@ -35,9 +36,24 @@ public class EmittedMetricsParser { */ public static Map getMetricsFromFiles( String rootDir, String instrumentationDirectory) { - Map metricsByWhen = new HashMap<>(); Path telemetryDir = Paths.get(rootDir + "/" + instrumentationDirectory, ".telemetry"); + Map> metricsByWhen = + parseAllMetricFiles(telemetryDir); + + return aggregateMetricsByScope(metricsByWhen); + } + + /** + * Parses all metric files in the given .telemetry directory and returns a map where the key is + * the 'when' condition and the value is a list of metrics grouped by scope. + * + * @param telemetryDir the path to the .telemetry directory + * @return a map of 'when' to list of metrics by scope + */ + private static Map> parseAllMetricFiles( + Path telemetryDir) { + Map> metricsByWhen = new HashMap<>(); if (Files.exists(telemetryDir) && Files.isDirectory(telemetryDir)) { try (Stream files = Files.list(telemetryDir)) { files @@ -49,14 +65,21 @@ public class EmittedMetricsParser { String when = content.substring(0, content.indexOf('\n')); String whenKey = when.replace("when: ", ""); - metricsByWhen.putIfAbsent(whenKey, new StringBuilder("metrics:\n")); - - // Skip the metric label ("metrics:") so we can aggregate into one list - int metricsIndex = content.indexOf("metrics:\n"); + int metricsIndex = content.indexOf("metrics_by_scope:"); if (metricsIndex != -1) { - String contentAfterMetrics = - content.substring(metricsIndex + "metrics:\n".length()); - metricsByWhen.get(whenKey).append(contentAfterMetrics); + String yaml = "when: " + whenKey + "\n" + content.substring(metricsIndex); + EmittedMetrics parsed; + try { + parsed = YamlHelper.emittedMetricsParser(yaml); + } catch (Exception e) { + logger.severe( + "Error parsing metrics file (" + path + "): " + e.getMessage()); + return; + } + if (parsed.getMetricsByScope() != null) { + metricsByWhen.putIfAbsent(whenKey, new ArrayList<>()); + metricsByWhen.get(whenKey).addAll(parsed.getMetricsByScope()); + } } } }); @@ -64,37 +87,80 @@ public class EmittedMetricsParser { logger.severe("Error reading metrics files: " + e.getMessage()); } } + return metricsByWhen; + } - return parseMetrics(metricsByWhen); + /** + * Aggregates metrics under the same scope for each 'when' condition, deduplicating metrics by + * name. + * + * @param metricsByWhen map of 'when' to list of metrics by scope + * @return a map of 'when' to aggregated EmittedMetrics + */ + private static Map aggregateMetricsByScope( + Map> metricsByWhen) { + Map result = new HashMap<>(); + for (Map.Entry> entry : metricsByWhen.entrySet()) { + String when = entry.getKey(); + List allScopes = entry.getValue(); + Map> metricsByScopeName = new HashMap<>(); + + for (EmittedMetrics.MetricsByScope scopeEntry : allScopes) { + String scope = scopeEntry.getScope(); + metricsByScopeName.putIfAbsent(scope, new HashMap<>()); + Map metricMap = metricsByScopeName.get(scope); + + for (EmittedMetrics.Metric metric : scopeEntry.getMetrics()) { + metricMap.put(metric.getName(), metric); // deduplicate by name + } + } + + List mergedScopes = new ArrayList<>(); + for (Map.Entry> scopeEntry : + metricsByScopeName.entrySet()) { + mergedScopes.add( + new EmittedMetrics.MetricsByScope( + scopeEntry.getKey(), new ArrayList<>(scopeEntry.getValue().values()))); + } + result.put(when, new EmittedMetrics(when, mergedScopes)); + } + return result; } /** * Takes in a raw string representation of the aggregated EmittedMetrics yaml map, separated by - * the `when`, indicating the conditions under which the metrics are emitted. deduplicates the - * metrics by name and then returns a new map of EmittedMetrics objects. + * the {@code when}, indicating the conditions under which the metrics are emitted. Deduplicates + * the metrics by name and then returns a new map of EmittedMetrics objects. * * @param input raw string representation of EmittedMetrics yaml - * @return {@code Map} where the key is the `when` condition + * @return map where the key is the {@code when} condition and the value is the corresponding + * EmittedMetrics + * @throws JsonProcessingException if parsing fails */ // visible for testing - public static Map parseMetrics(Map input) { + public static Map parseMetrics(Map input) + throws JsonProcessingException { Map metricsMap = new HashMap<>(); for (Map.Entry entry : input.entrySet()) { String when = entry.getKey(); StringBuilder content = entry.getValue(); EmittedMetrics metrics = YamlHelper.emittedMetricsParser(content.toString()); - if (metrics.getMetrics() == null) { + if (metrics.getMetricsByScope() == null) { continue; } - Map deduplicatedMetrics = new HashMap<>(); - for (EmittedMetrics.Metric metric : metrics.getMetrics()) { - deduplicatedMetrics.put(metric.getName(), metric); + List deduplicatedScopes = new ArrayList<>(); + for (EmittedMetrics.MetricsByScope scopeEntry : metrics.getMetricsByScope()) { + String scope = scopeEntry.getScope(); + Map dedupedMetrics = new HashMap<>(); + for (EmittedMetrics.Metric metric : scopeEntry.getMetrics()) { + dedupedMetrics.put(metric.getName(), metric); + } + deduplicatedScopes.add( + new EmittedMetrics.MetricsByScope(scope, new ArrayList<>(dedupedMetrics.values()))); } - - List uniqueMetrics = new ArrayList<>(deduplicatedMetrics.values()); - metricsMap.put(when, new EmittedMetrics(when, uniqueMetrics)); + metricsMap.put(when, new EmittedMetrics(when, deduplicatedScopes)); } return metricsMap; } diff --git a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/MetricParser.java b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/MetricParser.java new file mode 100644 index 0000000000..b1563555c5 --- /dev/null +++ b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/parsers/MetricParser.java @@ -0,0 +1,180 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.docs.parsers; + +import io.opentelemetry.instrumentation.docs.internal.EmittedMetrics; +import io.opentelemetry.instrumentation.docs.internal.InstrumentationModule; +import io.opentelemetry.instrumentation.docs.internal.TelemetryAttribute; +import io.opentelemetry.instrumentation.docs.utils.FileManager; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * This class is responsible for parsing metric files from the `.telemetry` directory of an + * instrumentation module and filtering them by scope. + */ +public class MetricParser { + + /** + * Retrieves metrics for a given instrumentation module, filtered by scope. + * + * @param module the instrumentation module + * @param fileManager the file manager to use for file operations + * @return a map where the key is the 'when' condition and the value is a list of metrics + */ + public static Map> getMetrics( + InstrumentationModule module, FileManager fileManager) { + Map metrics = + EmittedMetricsParser.getMetricsFromFiles(fileManager.rootDir(), module.getSrcPath()); + + if (metrics.isEmpty()) { + return new HashMap<>(); + } + + String scopeName = module.getScopeInfo().getName(); + return filterMetricsByScope(metrics, scopeName); + } + + /** + * Filters metrics by scope and aggregates attributes for each metric kind. + * + * @param metricsByScope the map of metrics by scope + * @param scopeName the name of the scope to filter metrics for + * @return a map of filtered metrics by 'when' + */ + private static Map> filterMetricsByScope( + Map metricsByScope, String scopeName) { + + Map> aggregatedMetrics = + new HashMap<>(); + + for (Map.Entry entry : metricsByScope.entrySet()) { + if (!hasValidMetrics(entry.getValue())) { + continue; + } + + String when = entry.getValue().getWhen(); + Map> result = + MetricAggregator.aggregateMetrics(when, entry.getValue(), scopeName); + + // Merge result into aggregatedMetrics + for (Map.Entry> e : + result.entrySet()) { + String whenKey = e.getKey(); + Map metricMap = + aggregatedMetrics.computeIfAbsent(whenKey, k -> new HashMap<>()); + + for (Map.Entry metricEntry : + e.getValue().entrySet()) { + String metricName = metricEntry.getKey(); + MetricAggregator.AggregatedMetricInfo newInfo = metricEntry.getValue(); + MetricAggregator.AggregatedMetricInfo existingInfo = metricMap.get(metricName); + if (existingInfo == null) { + metricMap.put(metricName, newInfo); + } else { + existingInfo.attributes.addAll(newInfo.attributes); + } + } + } + } + + return MetricAggregator.buildFilteredMetrics(aggregatedMetrics); + } + + private static boolean hasValidMetrics(EmittedMetrics metrics) { + return metrics != null && metrics.getMetricsByScope() != null; + } + + /** Helper class to aggregate metrics by scope and name. */ + static class MetricAggregator { + /** + * Aggregates metrics for a given 'when' condition, metrics object, and target scope name. + * + * @param when the 'when' condition + * @param metrics the EmittedMetrics object + * @param targetScopeName the scope name to filter by + * @return a map of aggregated metrics by 'when' and metric name + */ + public static Map> aggregateMetrics( + String when, EmittedMetrics metrics, String targetScopeName) { + Map> aggregatedMetrics = new HashMap<>(); + Map metricKindMap = + aggregatedMetrics.computeIfAbsent(when, k -> new HashMap<>()); + + for (EmittedMetrics.MetricsByScope metricsByScope : metrics.getMetricsByScope()) { + if (metricsByScope.getScope().equals(targetScopeName)) { + for (EmittedMetrics.Metric metric : metricsByScope.getMetrics()) { + AggregatedMetricInfo aggInfo = + metricKindMap.computeIfAbsent( + metric.getName(), + k -> + new AggregatedMetricInfo( + metric.getName(), + metric.getDescription(), + metric.getType(), + metric.getUnit())); + if (metric.getAttributes() != null) { + for (TelemetryAttribute attr : metric.getAttributes()) { + aggInfo.attributes.add(new TelemetryAttribute(attr.getName(), attr.getType())); + } + } + } + } + } + return aggregatedMetrics; + } + + /** + * Builds a filtered metrics map from aggregated metrics. + * + * @param aggregatedMetrics the aggregated metrics map + * @return a map where the key is the 'when' condition and the value is a list of metrics + */ + public static Map> buildFilteredMetrics( + Map> aggregatedMetrics) { + Map> result = new HashMap<>(); + for (Map.Entry> entry : + aggregatedMetrics.entrySet()) { + String when = entry.getKey(); + List metrics = result.computeIfAbsent(when, k -> new ArrayList<>()); + for (AggregatedMetricInfo aggInfo : entry.getValue().values()) { + metrics.add( + new EmittedMetrics.Metric( + aggInfo.name, + aggInfo.description, + aggInfo.type, + aggInfo.unit, + new ArrayList<>(aggInfo.attributes))); + } + } + return result; + } + + /** Data class to hold aggregated metric information. */ + static class AggregatedMetricInfo { + final String name; + final String description; + final String type; + final String unit; + final Set attributes = new HashSet<>(); + + AggregatedMetricInfo(String name, String description, String type, String unit) { + this.name = name; + this.description = description; + this.type = type; + this.unit = unit; + } + } + + private MetricAggregator() {} + } + + private MetricParser() {} +} diff --git a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/utils/YamlHelper.java b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/utils/YamlHelper.java index 1a1b24dc00..e111e6f2b3 100644 --- a/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/utils/YamlHelper.java +++ b/instrumentation-docs/src/main/java/io/opentelemetry/instrumentation/docs/utils/YamlHelper.java @@ -291,8 +291,8 @@ public class YamlHelper { return mapper.readValue(input, InstrumentationMetaData.class); } - public static EmittedMetrics emittedMetricsParser(String input) { - return new Yaml().loadAs(input, EmittedMetrics.class); + public static EmittedMetrics emittedMetricsParser(String input) throws JsonProcessingException { + return mapper.readValue(input, EmittedMetrics.class); } public static EmittedSpans emittedSpansParser(String input) throws JsonProcessingException { diff --git a/instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParserTest.java b/instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParserTest.java new file mode 100644 index 0000000000..a0b5a55917 --- /dev/null +++ b/instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/EmittedMetricsParserTest.java @@ -0,0 +1,128 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.docs.parsers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockStatic; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.opentelemetry.instrumentation.docs.internal.EmittedMetrics; +import io.opentelemetry.instrumentation.docs.utils.FileManager; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +@SuppressWarnings("NullAway") +class EmittedMetricsParserTest { + + @Test + void parseMetricsDeduplicatesMetricsByName() throws JsonProcessingException { + String input = + """ + metrics_by_scope: + - scope: io.opentelemetry.alibaba-druid-1.0 + metrics: + - name: metric1 + type: counter + - name: metric1 + type: counter + - name: metric2 + type: gauge + """; + + Map metricMap = new HashMap<>(); + metricMap.put("default", new StringBuilder(input)); + + Map result = EmittedMetricsParser.parseMetrics(metricMap); + List metricNames = + result.get("default").getMetricsByScope().get(0).getMetrics().stream() + .map(EmittedMetrics.Metric::getName) + .sorted() + .toList(); + + assertThat(metricNames).hasSize(2); + assertThat(metricNames).containsExactly("metric1", "metric2"); + } + + @Test + void parseMetricsHandlesEmptyInput() throws JsonProcessingException { + String input = "metrics_by_scope:\n"; + Map metricMap = new HashMap<>(); + metricMap.put("default", new StringBuilder(input)); + + Map result = EmittedMetricsParser.parseMetrics(metricMap); + assertThat(result).isEmpty(); + } + + @Test + void getMetricsFromFilesCombinesFilesCorrectly(@TempDir Path tempDir) throws IOException { + Path telemetryDir = Files.createDirectories(tempDir.resolve(".telemetry")); + + String file1Content = + """ + when: default + metrics_by_scope: + - scope: io.opentelemetry.MetricParserTest + metrics: + - name: metric1 + type: counter + """; + + String file2Content = + """ + when: default + metrics_by_scope: + - scope: io.opentelemetry.MetricParserTest + metrics: + - name: metric2 + type: gauge + """; + + Files.writeString(telemetryDir.resolve("metrics-1.yaml"), file1Content); + Files.writeString(telemetryDir.resolve("metrics-2.yaml"), file2Content); + + // Create a non-metrics file that should be ignored + Files.writeString(telemetryDir.resolve("other-file.yaml"), "some content"); + + try (MockedStatic fileManagerMock = mockStatic(FileManager.class)) { + fileManagerMock + .when( + () -> FileManager.readFileToString(telemetryDir.resolve("metrics-1.yaml").toString())) + .thenReturn(file1Content); + fileManagerMock + .when( + () -> FileManager.readFileToString(telemetryDir.resolve("metrics-2.yaml").toString())) + .thenReturn(file2Content); + + Map result = + EmittedMetricsParser.getMetricsFromFiles(tempDir.toString(), ""); + + EmittedMetrics.MetricsByScope metrics = + result.get("default").getMetricsByScope().stream() + .filter(scope -> scope.getScope().equals("io.opentelemetry.MetricParserTest")) + .findFirst() + .orElseThrow(); + + assertThat(metrics.getMetrics()).hasSize(2); + List metricNames = + metrics.getMetrics().stream().map(EmittedMetrics.Metric::getName).sorted().toList(); + assertThat(metricNames).containsExactly("metric1", "metric2"); + } + } + + @Test + void getMetricsFromFilesHandlesNonexistentDirectory() throws JsonProcessingException { + Map result = + EmittedMetricsParser.getMetricsFromFiles("/nonexistent", "path"); + assertThat(result).isEmpty(); + } +} diff --git a/instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/MetricParserTest.java b/instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/MetricParserTest.java index 088747876c..8d5a6e81ab 100644 --- a/instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/MetricParserTest.java +++ b/instrumentation-docs/src/test/java/io/opentelemetry/instrumentation/docs/parsers/MetricParserTest.java @@ -6,99 +6,110 @@ package io.opentelemetry.instrumentation.docs.parsers; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mockStatic; import io.opentelemetry.instrumentation.docs.internal.EmittedMetrics; -import io.opentelemetry.instrumentation.docs.utils.FileManager; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; +import io.opentelemetry.instrumentation.docs.internal.TelemetryAttribute; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.MockedStatic; @SuppressWarnings("NullAway") class MetricParserTest { - @Test - void parseMetricsDeduplicatesMetricsByName() { - String input = - """ - metrics: - - name: metric1 - type: counter - - name: metric1 - type: counter - - name: metric2 - type: gauge - """; + void testFiltersMetricsByScope() { + String targetScopeName = "my-instrumentation-scope"; - Map metricMap = new HashMap<>(); - metricMap.put("default", new StringBuilder(input)); + EmittedMetrics.Metric metric1 = createMetric("my.metric1", "desc1", "attr1"); + EmittedMetrics.Metric otherMetric = createMetric("other.metric", "desc2", "other.attr"); - Map result = EmittedMetricsParser.parseMetrics(metricMap); - List metricNames = - result.get("default").getMetrics().stream() - .map(EmittedMetrics.Metric::getName) - .sorted() - .toList(); + EmittedMetrics.MetricsByScope targetMetricsByScope = + new EmittedMetrics.MetricsByScope(targetScopeName, List.of(metric1)); - assertThat(metricNames).hasSize(2); - assertThat(metricNames).containsExactly("metric1", "metric2"); + EmittedMetrics.MetricsByScope otherMetricsByScope = + new EmittedMetrics.MetricsByScope("other-scope", List.of(otherMetric)); + + EmittedMetrics emittedMetrics = + new EmittedMetrics("default", List.of(targetMetricsByScope, otherMetricsByScope)); + + Map> metrics = + MetricParser.MetricAggregator.aggregateMetrics("default", emittedMetrics, targetScopeName); + + Map> result = + MetricParser.MetricAggregator.buildFilteredMetrics(metrics); + + assertThat(result.get("default")).hasSize(1); + assertThat(result.get("default").get(0).getName()).isEqualTo("my.metric1"); } @Test - void parseMetricsHandlesEmptyInput() { - String input = "metrics:\n"; - Map metricMap = new HashMap<>(); - metricMap.put("default", new StringBuilder(input)); + void testAggregatesAndDeduplicatesAttributes() { + String targetScopeName = "my-instrumentation-scope"; - Map result = EmittedMetricsParser.parseMetrics(metricMap); - assertThat(result).isEmpty(); + EmittedMetrics.Metric metric1 = + new EmittedMetrics.Metric( + "my.metric1", + "desc1", + "gauge", + "unit", + List.of( + new TelemetryAttribute("attr1", "STRING"), + new TelemetryAttribute("attr2", "STRING"))); + + EmittedMetrics.Metric metric1Dup = + new EmittedMetrics.Metric( + "my.metric1", + "desc1", + "gauge", + "unit", + List.of( + new TelemetryAttribute("attr1", "STRING"), + new TelemetryAttribute("attr3", "STRING"))); + + EmittedMetrics.MetricsByScope targetMetricsByScope = + new EmittedMetrics.MetricsByScope(targetScopeName, List.of(metric1, metric1Dup)); + + EmittedMetrics emittedMetrics = new EmittedMetrics("default", List.of(targetMetricsByScope)); + + Map> metrics = + MetricParser.MetricAggregator.aggregateMetrics("default", emittedMetrics, targetScopeName); + + Map> result = + MetricParser.MetricAggregator.buildFilteredMetrics(metrics); + List attrs = result.get("default").get(0).getAttributes(); + + assertThat(attrs).hasSize(3); + assertThat(attrs.stream().map(TelemetryAttribute::getName)) + .containsExactlyInAnyOrder("attr1", "attr2", "attr3"); } @Test - void getMetricsFromFilesCombinesFilesCorrectly(@TempDir Path tempDir) throws IOException { - Path telemetryDir = Files.createDirectories(tempDir.resolve(".telemetry")); + void testPreservesMetricMetadata() { + String targetScopeName = "my-instrumentation-scope"; - String file1Content = "when: default\n metrics:\n - name: metric1\n type: counter\n"; - String file2Content = "when: default\n metrics:\n - name: metric2\n type: gauge\n"; + EmittedMetrics.Metric metric1 = + createMetric("my.metric1", "description of my.metric1", "attr1"); - Files.writeString(telemetryDir.resolve("metrics-1.yaml"), file1Content); - Files.writeString(telemetryDir.resolve("metrics-2.yaml"), file2Content); + EmittedMetrics.MetricsByScope targetMetricsByScope = + new EmittedMetrics.MetricsByScope(targetScopeName, List.of(metric1)); - // Create a non-metrics file that should be ignored - Files.writeString(telemetryDir.resolve("other-file.yaml"), "some content"); + EmittedMetrics emittedMetrics = new EmittedMetrics("default", List.of(targetMetricsByScope)); - try (MockedStatic fileManagerMock = mockStatic(FileManager.class)) { - fileManagerMock - .when( - () -> FileManager.readFileToString(telemetryDir.resolve("metrics-1.yaml").toString())) - .thenReturn(file1Content); - fileManagerMock - .when( - () -> FileManager.readFileToString(telemetryDir.resolve("metrics-2.yaml").toString())) - .thenReturn(file2Content); + Map> metrics = + MetricParser.MetricAggregator.aggregateMetrics("default", emittedMetrics, targetScopeName); - Map result = - EmittedMetricsParser.getMetricsFromFiles(tempDir.toString(), ""); + Map> result = + MetricParser.MetricAggregator.buildFilteredMetrics(metrics); - EmittedMetrics metrics = result.get("default"); - - assertThat(metrics.getMetrics()).hasSize(2); - List metricNames = - metrics.getMetrics().stream().map(EmittedMetrics.Metric::getName).sorted().toList(); - assertThat(metricNames).containsExactly("metric1", "metric2"); - } + EmittedMetrics.Metric foundMetric = result.get("default").get(0); + assertThat(foundMetric.getName()).isEqualTo("my.metric1"); + assertThat(foundMetric.getDescription()).isEqualTo("description of my.metric1"); + assertThat(foundMetric.getType()).isEqualTo("gauge"); + assertThat(foundMetric.getUnit()).isEqualTo("unit"); } - @Test - void getMetricsFromFilesHandlesNonexistentDirectory() { - Map result = - EmittedMetricsParser.getMetricsFromFiles("/nonexistent", "path"); - assertThat(result).isEmpty(); + private static EmittedMetrics.Metric createMetric( + String name, String description, String attrName) { + return new EmittedMetrics.Metric( + name, description, "gauge", "unit", List.of(new TelemetryAttribute(attrName, "STRING"))); } } diff --git a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/AgentTestRunner.java b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/AgentTestRunner.java index f608ec9adc..1199ef7b79 100644 --- a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/AgentTestRunner.java +++ b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/AgentTestRunner.java @@ -73,7 +73,7 @@ public final class AgentTestRunner extends InstrumentationTestRunner { } String path = Paths.get(resource.getPath()).toString(); - MetaDataCollector.writeTelemetryToFiles(path, metrics, tracesByScope); + MetaDataCollector.writeTelemetryToFiles(path, metricsByScope, tracesByScope); } // additional library ignores are ignored during tests, because they can make it really diff --git a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/InstrumentationTestRunner.java b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/InstrumentationTestRunner.java index 03479afb38..8021791b35 100644 --- a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/InstrumentationTestRunner.java +++ b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/InstrumentationTestRunner.java @@ -51,7 +51,7 @@ import org.awaitility.core.ConditionTimeoutException; public abstract class InstrumentationTestRunner { private final TestInstrumenters testInstrumenters; - protected Map metrics = new HashMap<>(); + protected Map> metricsByScope = new HashMap<>(); /** * Stores traces by scope, where each scope contains a map of span kinds to a map of attribute @@ -205,8 +205,12 @@ public abstract class InstrumentationTestRunner { private void collectEmittedMetrics(List metrics) { for (MetricData metric : metrics) { - if (!this.metrics.containsKey(metric.getName())) { - this.metrics.put(metric.getName(), metric); + Map scopeMap = + this.metricsByScope.computeIfAbsent( + metric.getInstrumentationScopeInfo(), m -> new HashMap<>()); + + if (!scopeMap.containsKey(metric.getName())) { + scopeMap.put(metric.getName(), metric); } } } diff --git a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/LibraryTestRunner.java b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/LibraryTestRunner.java index 690632bceb..b97ecbd8cc 100644 --- a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/LibraryTestRunner.java +++ b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/LibraryTestRunner.java @@ -130,7 +130,7 @@ public final class LibraryTestRunner extends InstrumentationTestRunner { } String path = Paths.get(resource.getPath()).toString(); - MetaDataCollector.writeTelemetryToFiles(path, metrics, tracesByScope); + MetaDataCollector.writeTelemetryToFiles(path, metricsByScope, tracesByScope); } } diff --git a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/internal/MetaDataCollector.java b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/internal/MetaDataCollector.java index c60f71c300..84849d2e72 100644 --- a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/internal/MetaDataCollector.java +++ b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/internal/MetaDataCollector.java @@ -39,13 +39,13 @@ public final class MetaDataCollector { public static void writeTelemetryToFiles( String path, - Map metrics, + Map> metricsByScope, Map, AttributeType>>> spansByScopeAndKind) throws IOException { String moduleRoot = extractInstrumentationPath(path); - writeMetricData(moduleRoot, metrics); + writeMetricData(moduleRoot, metricsByScope); writeSpanData(moduleRoot, spansByScopeAndKind); } @@ -125,10 +125,12 @@ public final class MetaDataCollector { } } - private static void writeMetricData(String instrumentationPath, Map metrics) + private static void writeMetricData( + String instrumentationPath, + Map> metricsByScope) throws IOException { - if (metrics.isEmpty()) { + if (metricsByScope.isEmpty()) { return; } @@ -144,26 +146,36 @@ public final class MetaDataCollector { writer.write("when: " + when + "\n"); - writer.write("metrics:\n"); - for (MetricData metric : metrics.values()) { - writer.write(" - name: " + metric.getName() + "\n"); - writer.write(" description: " + metric.getDescription() + "\n"); - writer.write(" type: " + metric.getType().toString() + "\n"); - writer.write(" unit: " + sanitizeUnit(metric.getUnit()) + "\n"); - writer.write(" attributes: \n"); - metric.getData().getPoints().stream() - .findFirst() - .get() - .getAttributes() - .forEach( - (key, value) -> { - try { - writer.write(" - name: " + key.getKey() + "\n"); - writer.write(" type: " + key.getType().toString() + "\n"); - } catch (IOException e) { - throw new IllegalStateException(e); - } - }); + writer.write("metrics_by_scope:\n"); + + for (Map.Entry> entry : + metricsByScope.entrySet()) { + InstrumentationScopeInfo scope = entry.getKey(); + Map metrics = entry.getValue(); + + writer.write(" - scope: " + scope.getName() + "\n"); + writer.write(" metrics:\n"); + + for (MetricData metric : metrics.values()) { + writer.write(" - name: " + metric.getName() + "\n"); + writer.write(" description: " + metric.getDescription() + "\n"); + writer.write(" type: " + metric.getType().toString() + "\n"); + writer.write(" unit: " + sanitizeUnit(metric.getUnit()) + "\n"); + writer.write(" attributes: \n"); + metric.getData().getPoints().stream() + .findFirst() + .get() + .getAttributes() + .forEach( + (key, value) -> { + try { + writer.write(" - name: " + key.getKey() + "\n"); + writer.write(" type: " + key.getType().toString() + "\n"); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + } } } }