This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/2.0.0 by this push:
     new 0ad8ffe098 Expose OTLP metrics inventory
0ad8ffe098 is described below

commit 0ad8ffe09844be75aa47af6a110a86cba7a57d53
Author: Logic <[email protected]>
AuthorDate: Wed Jun 10 07:45:18 2026 +0800

    Expose OTLP metrics inventory
---
 .../dto/metrics/OtlpMetricsInventoryDto.java       |  60 ++++++++++
 .../dto/OtlpWorkspaceDtoMigrationTest.java         |  14 +++
 .../controller/OtlpIngestionController.java        |  16 +++
 .../service/OtlpIngestionWorkspaceService.java     |   5 +
 .../impl/OtlpIngestionWorkspaceServiceImpl.java    | 124 +++++++++++++++++++++
 .../controller/OtlpIngestionControllerTest.java    |  42 +++++++
 .../OtlpIngestionWorkspaceServiceImplTest.java     |  70 ++++++++++++
 web-next/lib/otlp-metrics/controller.test.ts       |  54 ++++++++-
 web-next/lib/otlp-metrics/controller.ts            |  43 ++++++-
 web-next/lib/types.ts                              |  13 +++
 10 files changed, 439 insertions(+), 2 deletions(-)

diff --git 
a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/metrics/OtlpMetricsInventoryDto.java
 
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/metrics/OtlpMetricsInventoryDto.java
new file mode 100644
index 0000000000..fc4a9108a9
--- /dev/null
+++ 
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/observability/dto/metrics/OtlpMetricsInventoryDto.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.common.observability.dto.metrics;
+
+import java.util.List;
+import java.util.Map;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Source-backed OTLP metrics inventory for a service/entity context.
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class OtlpMetricsInventoryDto {
+
+    private OtlpMetricsConsoleDto.Context context;
+
+    private String source;
+
+    private int total;
+
+    private List<Item> items;
+
+    /**
+     * Metric inventory item discovered from PromQL frames or recent intake 
fallback.
+     */
+    @Data
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class Item {
+
+        private String metricName;
+
+        private String family;
+
+        private int timeSeriesCount;
+
+        private Long latestObservedAt;
+
+        private Map<String, String> labels;
+    }
+}
diff --git 
a/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java
 
b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java
index 3f18c54783..08956dd9ea 100644
--- 
a/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java
+++ 
b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/observability/dto/OtlpWorkspaceDtoMigrationTest.java
@@ -25,6 +25,7 @@ import 
org.apache.hertzbeat.common.observability.dto.binding.TelemetryIdentitySn
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionGuideDto;
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionOverviewDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsConsoleDto;
+import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsInventoryDto;
 import org.apache.hertzbeat.common.observability.dto.log.EntityLogQueryHint;
 import org.apache.hertzbeat.common.observability.dto.log.EntityLogSummaryInfo;
 import 
org.apache.hertzbeat.common.observability.dto.trace.EntityTraceQueryHintDto;
@@ -67,6 +68,18 @@ class OtlpWorkspaceDtoMigrationTest {
                 null,
                 null
         );
+        OtlpMetricsInventoryDto inventory = new OtlpMetricsInventoryDto(
+                console.getContext(),
+                "promql-inventory",
+                1,
+                List.of(new OtlpMetricsInventoryDto.Item(
+                        "http_server_duration",
+                        "latency",
+                        2,
+                        2000L,
+                        Map.of("__name__", "http_server_duration", 
"service_name", "checkout")
+                ))
+        );
         OtlpEntityBindingSummaryDto bindingSummary = new 
OtlpEntityBindingSummaryDto(
                 List.of("service.name"),
                 List.of("checkout"),
@@ -119,6 +132,7 @@ class OtlpWorkspaceDtoMigrationTest {
         assertEquals("HTTP", guide.getHttpProtocolLabel());
         assertEquals(2, overview.getActiveSignalCount());
         assertEquals("greptime", console.getDatasource());
+        assertEquals("http_server_duration", 
inventory.getItems().getFirst().getMetricName());
         assertEquals("checkout", 
bindingSummary.getRecentServices().getFirst());
         assertEquals("checkout", identitySnapshot.getServiceName());
         assertEquals("trace-id", traceQueryHint.getTraceId());
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
index e61671f9c8..bfc2f34217 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionController.java
@@ -27,6 +27,7 @@ import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionGuid
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionOverviewDto;
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionRedSummaryDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsConsoleDto;
+import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsInventoryDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpRelatedMetricsDto;
 import 
org.apache.hertzbeat.observability.ingestion.red.OtlpIngestionRedSummaryService;
 import 
org.apache.hertzbeat.observability.ingestion.service.OtlpIngestionWorkspaceService;
@@ -96,6 +97,21 @@ public class OtlpIngestionController {
                 temporalAggregation, step, limit)));
     }
 
+    @GetMapping("/metrics/inventory")
+    @Operation(summary = "OTLP metrics inventory for a service or entity 
context")
+    public ResponseEntity<Message<OtlpMetricsInventoryDto>> metricsInventory(
+            @RequestParam(value = "entityId", required = false) Long entityId,
+            @RequestParam(value = "entityType", required = false) String 
entityType,
+            @RequestParam(value = "start", required = false) Long start,
+            @RequestParam(value = "end", required = false) Long end,
+            @RequestParam(value = "serviceName", required = false) String 
serviceName,
+            @RequestParam(value = "serviceNamespace", required = false) String 
serviceNamespace,
+            @RequestParam(value = "environment", required = false) String 
environment,
+            @RequestParam(value = "limit", required = false) String limit) {
+        return 
ResponseEntity.ok(Message.success(otlpIngestionWorkspaceService.getMetricsInventory(
+                entityId, entityType, start, end, serviceName, 
serviceNamespace, environment, limit)));
+    }
+
     @GetMapping("/metrics/related")
     @Operation(summary = "OTLP related metrics discovery for a signal context")
     public ResponseEntity<Message<OtlpRelatedMetricsDto>> relatedMetrics(
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
index fc2eaf37d9..37ea8d7491 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/OtlpIngestionWorkspaceService.java
@@ -22,6 +22,7 @@ import 
org.apache.hertzbeat.common.observability.dto.binding.OtlpEntityBindingSu
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionGuideDto;
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionOverviewDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsConsoleDto;
+import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsInventoryDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpRelatedMetricsDto;
 
 /**
@@ -40,6 +41,10 @@ public interface OtlpIngestionWorkspaceService {
                                             String filter, String groupBy, 
String aggregation,
                                             String temporalAggregation, String 
step, String limit);
 
+    OtlpMetricsInventoryDto getMetricsInventory(Long entityId, String 
entityType, Long start, Long end,
+                                                String serviceName, String 
serviceNamespace, String environment,
+                                                String limit);
+
     OtlpRelatedMetricsDto getRelatedMetrics(Long entityId, String entityType, 
Long start, Long end, String serviceName,
                                             String serviceNamespace, String 
environment, String filter,
                                             String operationName, String 
limit);
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
index 58ae8ba0ed..95d7cfffc9 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImpl.java
@@ -47,6 +47,7 @@ import 
org.apache.hertzbeat.common.observability.dto.binding.TelemetryIdentitySn
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionGuideDto;
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionOverviewDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsConsoleDto;
+import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsInventoryDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpRelatedMetricsDto;
 import 
org.apache.hertzbeat.common.observability.model.EntityCanonicalIdentityRegistry;
 import org.apache.hertzbeat.common.observability.dto.trace.TraceListItemDto;
@@ -786,6 +787,37 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
                 groupBy, aggregation, temporalAggregation, step, limit);
     }
 
+    @Override
+    public OtlpMetricsInventoryDto getMetricsInventory(Long entityId, String 
entityType, Long start, Long end,
+                                                       String serviceName, 
String serviceNamespace, String environment,
+                                                       String limit) {
+        long resolvedEnd = end == null || end <= 0 ? 
System.currentTimeMillis() : end;
+        long resolvedStart = start == null || start <= 0 || start >= 
resolvedEnd
+                ? resolvedEnd - DEFAULT_CONSOLE_LOOKBACK_MILLIS
+                : start;
+        int resolvedLimit = resolveRelatedMetricsLimit(limit);
+        OtlpMetricsConsoleDto.Context context = resolveMetricsConsoleContext(
+                entityId, entityType, resolvedStart, resolvedEnd, serviceName, 
serviceNamespace, environment
+        );
+        List<OtlpMetricsInventoryDto.Item> items = 
metricQueryRepository.hasPromqlExecutor()
+                ? collectPromqlMetricsInventoryItems(context, resolvedStart, 
resolvedEnd, resolvedLimit)
+                : List.of();
+        if (!items.isEmpty()) {
+            return new OtlpMetricsInventoryDto(context, "promql-inventory", 
items.size(), items);
+        }
+        List<OtlpMetricsInventoryDto.Item> fallbackItems = 
candidateMetricNames(context).stream()
+                .limit(resolvedLimit)
+                .map(metricName -> new OtlpMetricsInventoryDto.Item(
+                        metricName,
+                        relatedMetricFamily(metricName),
+                        0,
+                        null,
+                        serviceContextResourceMatch(context)
+                ))
+                .toList();
+        return new OtlpMetricsInventoryDto(context, "recent-intake-fallback", 
fallbackItems.size(), fallbackItems);
+    }
+
     @Override
     public OtlpRelatedMetricsDto getRelatedMetrics(Long entityId, String 
entityType, Long start, Long end, String serviceName,
                                                    String serviceNamespace, 
String environment,
@@ -886,6 +918,59 @@ public class OtlpIngestionWorkspaceServiceImpl implements 
OtlpIngestionWorkspace
         return normalizeCandidateMetricNames(candidates);
     }
 
+    private List<OtlpMetricsInventoryDto.Item> 
collectPromqlMetricsInventoryItems(
+            OtlpMetricsConsoleDto.Context context,
+            long start,
+            long end,
+            int limit) {
+        String query = buildRelatedMetricInventoryQuery(context);
+        if (!StringUtils.hasText(query)) {
+            return List.of();
+        }
+        MetricQueryRepository.PromqlRangeQueryResult result = 
metricQueryRepository.queryPromqlRange(
+                RELATED_METRICS_INVENTORY_REF_ID,
+                query,
+                start,
+                end,
+                resolvePromqlStep(start, end, null)
+        );
+        if (result == null) {
+            return List.of();
+        }
+        if (result.errorMessage() != null) {
+            log.debug("query OTLP metrics inventory failed: {}", 
result.errorMessage());
+            return List.of();
+        }
+        return buildPromqlMetricInventoryItems(result.results()).stream()
+                .limit(limit)
+                .toList();
+    }
+
+    private List<OtlpMetricsInventoryDto.Item> 
buildPromqlMetricInventoryItems(DatasourceQueryData results) {
+        if (results == null || CollectionUtils.isEmpty(results.getFrames())) {
+            return List.of();
+        }
+        LinkedHashMap<String, MetricInventoryAccumulator> accumulators = new 
LinkedHashMap<>();
+        for (DatasourceQueryData.SchemaData frame : results.getFrames()) {
+            if (frame == null || frame.getSchema() == null || 
CollectionUtils.isEmpty(frame.getSchema().getLabels())) {
+                continue;
+            }
+            Map<String, String> labels = frame.getSchema().getLabels();
+            String metricName = trimToNull(labels.get("__name__"));
+            if (!StringUtils.hasText(metricName)) {
+                continue;
+            }
+            MetricInventoryAccumulator accumulator = 
accumulators.computeIfAbsent(
+                    metricName,
+                    key -> new MetricInventoryAccumulator(metricName, labels)
+            );
+            accumulator.addFrame(frame);
+        }
+        return accumulators.values().stream()
+                .map(MetricInventoryAccumulator::toItem)
+                .toList();
+    }
+
     private List<String> 
collectPromqlRelatedMetricNames(OtlpMetricsConsoleDto.Context context, long 
start, long end) {
         String query = buildRelatedMetricInventoryQuery(context);
         if (!StringUtils.hasText(query)) {
@@ -2466,6 +2551,45 @@ public class OtlpIngestionWorkspaceServiceImpl 
implements OtlpIngestionWorkspace
         return String.join(", ", groupLabels);
     }
 
+    private final class MetricInventoryAccumulator {
+
+        private final String metricName;
+        private final Map<String, String> labels;
+        private int timeSeriesCount;
+        private Long latestObservedAt;
+
+        private MetricInventoryAccumulator(String metricName, Map<String, 
String> labels) {
+            this.metricName = normalizePromqlMetricName(metricName);
+            this.labels = labels == null ? Map.of() : Map.copyOf(labels);
+        }
+
+        private void addFrame(DatasourceQueryData.SchemaData frame) {
+            timeSeriesCount++;
+            if (CollectionUtils.isEmpty(frame.getData())) {
+                return;
+            }
+            for (Object[] row : frame.getData()) {
+                if (row == null || row.length == 0) {
+                    continue;
+                }
+                Long rowTimestamp = numberToLong(row[0]);
+                if (rowTimestamp != null && (latestObservedAt == null || 
rowTimestamp > latestObservedAt)) {
+                    latestObservedAt = rowTimestamp;
+                }
+            }
+        }
+
+        private OtlpMetricsInventoryDto.Item toItem() {
+            return new OtlpMetricsInventoryDto.Item(
+                    metricName,
+                    relatedMetricFamily(metricName),
+                    timeSeriesCount,
+                    latestObservedAt,
+                    labels
+            );
+        }
+    }
+
     private String normalizeMetricsGroupByLabel(String label) {
         String normalized = trimToNull(label);
         if (!StringUtils.hasText(normalized)) {
diff --git 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
index ecf3505c0a..61cb651a81 100644
--- 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
+++ 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/controller/OtlpIngestionControllerTest.java
@@ -32,6 +32,7 @@ import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionGuid
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionOverviewDto;
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionRedSummaryDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsConsoleDto;
+import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsInventoryDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpRelatedMetricsDto;
 import 
org.apache.hertzbeat.observability.ingestion.red.OtlpIngestionRedSummaryService;
 import 
org.apache.hertzbeat.observability.ingestion.service.OtlpIngestionWorkspaceService;
@@ -260,6 +261,47 @@ class OtlpIngestionControllerTest {
                         null, "span.kind=\"server\"", null, null, "rate", 
"60", "1");
     }
 
+    @Test
+    void shouldReturnWrappedMetricsInventoryPayload() throws Exception {
+        OtlpMetricsInventoryDto inventory = new OtlpMetricsInventoryDto(
+                new OtlpMetricsConsoleDto.Context(42L, "service", "Checkout 
API", "checkout", "commerce", "prod",
+                        1000L, 2000L),
+                "promql-inventory",
+                1,
+                List.of(new OtlpMetricsInventoryDto.Item(
+                        "http_server_duration",
+                        "latency",
+                        2,
+                        2000L,
+                        java.util.Map.of("__name__", "http_server_duration", 
"service_name", "checkout")
+                ))
+        );
+        when(otlpIngestionWorkspaceService.getMetricsInventory(42L, "service", 
1000L, 2000L, "checkout", "commerce", "prod", "20"))
+                .thenReturn(inventory);
+
+        mockMvc.perform(get("/api/ingestion/otlp/metrics/inventory")
+                        .param("entityId", "42")
+                        .param("entityType", "service")
+                        .param("start", "1000")
+                        .param("end", "2000")
+                        .param("serviceName", "checkout")
+                        .param("serviceNamespace", "commerce")
+                        .param("environment", "prod")
+                        .param("limit", "20"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.context.entityId").value(42))
+                .andExpect(jsonPath("$.data.source").value("promql-inventory"))
+                .andExpect(jsonPath("$.data.total").value(1))
+                
.andExpect(jsonPath("$.data.items[0].metricName").value("http_server_duration"))
+                .andExpect(jsonPath("$.data.items[0].family").value("latency"))
+                
.andExpect(jsonPath("$.data.items[0].timeSeriesCount").value(2))
+                
.andExpect(jsonPath("$.data.items[0].labels.service_name").value("checkout"));
+
+        verify(otlpIngestionWorkspaceService)
+                .getMetricsInventory(42L, "service", 1000L, 2000L, "checkout", 
"commerce", "prod", "20");
+    }
+
     @Test
     void shouldReturnWrappedRelatedMetricsPayload() throws Exception {
         OtlpRelatedMetricsDto related = new OtlpRelatedMetricsDto(
diff --git 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
index 92524b7ee6..8f0c232bc6 100644
--- 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
+++ 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/ingestion/service/impl/OtlpIngestionWorkspaceServiceImplTest.java
@@ -50,6 +50,7 @@ import 
org.apache.hertzbeat.common.observability.dto.binding.OtlpEntityBindingSu
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionGuideDto;
 import 
org.apache.hertzbeat.common.observability.dto.ingestion.OtlpIngestionOverviewDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsConsoleDto;
+import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpMetricsInventoryDto;
 import 
org.apache.hertzbeat.common.observability.dto.metrics.OtlpRelatedMetricsDto;
 import org.apache.hertzbeat.common.observability.dto.trace.TraceListItemDto;
 import 
org.apache.hertzbeat.common.observability.gateway.ObservabilitySignalIntakeGateway;
@@ -1331,6 +1332,75 @@ class OtlpIngestionWorkspaceServiceImplTest {
         );
     }
 
+    @Test
+    void metricsInventoryReturnsPromqlSeriesBackedItems() {
+        
when(workspaceQueryGateway.findEntityById(42L)).thenReturn(java.util.Optional.empty());
+        
when(workspaceQueryGateway.findIdentitiesByEntityId(42L)).thenReturn(List.of());
+        when(metricQueryRepository.hasPromqlExecutor()).thenReturn(true);
+        when(metricQueryRepository.queryPromqlRange(
+                eq("otlp-related-metrics-inventory"),
+                anyString(),
+                eq(1_000L),
+                eq(2_000L),
+                anyString()
+        )).thenReturn(promqlSuccess(new DatasourceQueryData(
+                "otlp-related-metrics-inventory",
+                200,
+                null,
+                List.of(
+                        new DatasourceQueryData.SchemaData(
+                                new DatasourceQueryData.MetricSchema(
+                                        List.of(
+                                                new 
DatasourceQueryData.MetricField("__ts__", "time", null),
+                                                new 
DatasourceQueryData.MetricField("__value__", "number", null)
+                                        ),
+                                        Map.of("__name__", 
"http_server_duration", "service_name", "checkout", "http_route", "/checkout"),
+                                        Map.of()
+                                ),
+                                Collections.singletonList(new Object[] 
{1_000L, 12.0})
+                        ),
+                        new DatasourceQueryData.SchemaData(
+                                new DatasourceQueryData.MetricSchema(
+                                        List.of(
+                                                new 
DatasourceQueryData.MetricField("__ts__", "time", null),
+                                                new 
DatasourceQueryData.MetricField("__value__", "number", null)
+                                        ),
+                                        Map.of("__name__", 
"http_server_duration", "service_name", "checkout", "http_route", "/cart"),
+                                        Map.of()
+                                ),
+                                Collections.singletonList(new Object[] 
{2_000L, 14.0})
+                        )
+                )
+        )));
+
+        OtlpMetricsInventoryDto inventory = 
otlpIngestionWorkspaceService.getMetricsInventory(
+                42L,
+                "service",
+                1_000L,
+                2_000L,
+                "checkout",
+                "commerce",
+                "prod",
+                "20"
+        );
+
+        assertEquals("promql-inventory", inventory.getSource());
+        assertEquals(1, inventory.getTotal());
+        assertEquals("http_server_duration", 
inventory.getItems().getFirst().getMetricName());
+        assertEquals("latency", inventory.getItems().getFirst().getFamily());
+        assertEquals(2, inventory.getItems().getFirst().getTimeSeriesCount());
+        assertEquals(2_000L, 
inventory.getItems().getFirst().getLatestObservedAt());
+        verify(metricQueryRepository).queryPromqlRange(
+                eq("otlp-related-metrics-inventory"),
+                argThat(query -> query.contains("sum by (__name__)")
+                        && query.contains("service_name=\"checkout\"")
+                        && query.contains("service_namespace=\"commerce\"")),
+                eq(1_000L),
+                eq(2_000L),
+                anyString()
+        );
+    }
+
     @Test
     void metricsConsoleAppliesFilterWhenQueryIsExplicitMetricName() {
         String expectedQuery = 
groupedMetricPromql("__name__=\"http_server_request_duration_count\", "
diff --git a/web-next/lib/otlp-metrics/controller.test.ts 
b/web-next/lib/otlp-metrics/controller.test.ts
index e122dbf146..bd23320b9b 100644
--- a/web-next/lib/otlp-metrics/controller.test.ts
+++ b/web-next/lib/otlp-metrics/controller.test.ts
@@ -1,5 +1,11 @@
 import { describe, expect, it, vi } from 'vitest';
-import { buildOtlpMetricsConsoleUrl, loadOtlpMetricsConsole, 
queryStateFromParams } from './controller';
+import {
+  buildOtlpMetricsConsoleUrl,
+  buildOtlpMetricsInventoryUrl,
+  loadOtlpMetricsConsole,
+  loadOtlpMetricsInventory,
+  queryStateFromParams
+} from './controller';
 
 describe('otlp metrics controller', () => {
   it('loads metrics console data from the existing endpoint', async () => {
@@ -47,6 +53,52 @@ describe('otlp metrics controller', () => {
     
).toBe('/ingestion/otlp/metrics/console?query=http_server_duration_milliseconds_count&filter=service.name%3D%22checkout%22&aggregation=sum&temporalAggregation=rate&groupBy=service_name&step=60&limit=25&traceId=trace-1&spanId=span-1&serviceName=checkout&start=1000&end=2000');
   });
 
+  it('builds and loads metrics inventory from entity and service context 
only', async () => {
+    const apiGet = vi.fn().mockResolvedValue({
+      source: 'promql-inventory',
+      items: [{ metricName: 'http_server_duration', family: 'latency' }]
+    });
+    const query = {
+      entityId: '7',
+      entityType: 'service',
+      serviceName: 'checkout',
+      serviceNamespace: 'commerce',
+      environment: 'prod',
+      start: '1000',
+      end: '2000',
+      limit: '20',
+      query: 'ignored',
+      filter: 'ignored',
+      inventorySearch: 'ignored',
+      inventorySort: 'time-series',
+      relatedMetricSource: 'ignored'
+    } as const;
+
+    expect(buildOtlpMetricsInventoryUrl(query)).toBe(
+      
'/ingestion/otlp/metrics/inventory?entityId=7&entityType=service&serviceName=checkout&serviceNamespace=commerce&environment=prod&start=1000&end=2000&limit=20'
+    );
+    await expect(loadOtlpMetricsInventory(apiGet as any, 
query)).resolves.toEqual({
+      source: 'promql-inventory',
+      items: [{ metricName: 'http_server_duration', family: 'latency' }]
+    });
+    expect(apiGet).toHaveBeenCalledWith(
+      
'/ingestion/otlp/metrics/inventory?entityId=7&entityType=service&serviceName=checkout&serviceNamespace=commerce&environment=prod&start=1000&end=2000&limit=20'
+    );
+  });
+
+  it('rejects invalid inventory entity, time, and limit params before calling 
the endpoint', () => {
+    expect(
+      buildOtlpMetricsInventoryUrl({
+        entityId: '7.5',
+        entityType: 'service',
+        serviceName: 'checkout',
+        start: '1777484896189.989',
+        end: '1777485856189.989',
+        limit: '0'
+      })
+    
).toBe('/ingestion/otlp/metrics/inventory?entityType=service&serviceName=checkout');
+  });
+
   it('keeps client-only metrics inspector and selected series state out of the 
console endpoint', () => {
     expect(queryStateFromParams(new 
URLSearchParams('query=http.server.duration&inspector=table&series=http.server.duration-1&inventorySearch=checkout&inventorySort=time-series&inventoryPageSize=20&inventoryPageIndex=2&seriesAttributeSearch=deployment'))).toMatchObject({
       query: 'http.server.duration',
diff --git a/web-next/lib/otlp-metrics/controller.ts 
b/web-next/lib/otlp-metrics/controller.ts
index 66d765122e..d3277fe20e 100644
--- a/web-next/lib/otlp-metrics/controller.ts
+++ b/web-next/lib/otlp-metrics/controller.ts
@@ -1,6 +1,6 @@
 import { readEntityIdRouteParam, readEpochMillisRouteParam, 
stripReturnLabelFromHref, type SignalRouteContext } from 
'../signal-route-context';
 import { normalizeTimeContextValue } from '../time-context';
-import type { OtlpMetricsConsole } from '@/lib/types';
+import type { OtlpMetricsConsole, OtlpMetricsInventory } from '@/lib/types';
 
 type ApiGetter = <T>(url: string) => Promise<T>;
 
@@ -172,6 +172,47 @@ export function buildOtlpMetricsConsoleUrl(query: 
OtlpMetricsQueryState) {
   return params.size > 0 ? 
`/ingestion/otlp/metrics/console?${params.toString()}` : 
'/ingestion/otlp/metrics/console';
 }
 
+export function buildOtlpMetricsInventoryUrl(query: OtlpMetricsQueryState = 
{}) {
+  const params = new URLSearchParams();
+  const contextKeys = [
+    'entityId',
+    'entityType',
+    'serviceName',
+    'serviceNamespace',
+    'environment',
+    'start',
+    'end',
+    'limit'
+  ] as const;
+  contextKeys.forEach(key => {
+    const value = query[key];
+    if (value == null || String(value).trim() === '') {
+      return;
+    }
+    if (key === 'entityId') {
+      const entityId = readEntityIdRouteParam(String(value));
+      if (entityId) params.set(key, entityId);
+      return;
+    }
+    if (key === 'start' || key === 'end') {
+      const timeValue = normalizeTimeContextValue(key, String(value));
+      if (timeValue) params.set(key, timeValue);
+      return;
+    }
+    if (key === 'limit') {
+      const positiveInteger = readPositiveIntegerRouteParam(String(value));
+      if (positiveInteger) params.set(key, positiveInteger);
+      return;
+    }
+    params.set(key, String(value));
+  });
+  return params.size > 0 ? 
`/ingestion/otlp/metrics/inventory?${params.toString()}` : 
'/ingestion/otlp/metrics/inventory';
+}
+
 export async function loadOtlpMetricsConsole(apiGet: ApiGetter, query: 
OtlpMetricsQueryState = {}) {
   return apiGet<OtlpMetricsConsole>(buildOtlpMetricsConsoleUrl(query));
 }
+
+export async function loadOtlpMetricsInventory(apiGet: ApiGetter, query: 
OtlpMetricsQueryState = {}) {
+  return apiGet<OtlpMetricsInventory>(buildOtlpMetricsInventoryUrl(query));
+}
diff --git a/web-next/lib/types.ts b/web-next/lib/types.ts
index 00636d3716..b6da0ab3e5 100644
--- a/web-next/lib/types.ts
+++ b/web-next/lib/types.ts
@@ -372,6 +372,19 @@ export interface OtlpMetricsConsole {
   };
 }
 
+export interface OtlpMetricsInventory {
+  context?: OtlpMetricsConsole['context'];
+  source?: string | null;
+  total?: number;
+  items?: Array<{
+    metricName?: string | null;
+    family?: string | null;
+    timeSeriesCount?: number;
+    latestObservedAt?: number | null;
+    labels?: Record<string, string>;
+  }>;
+}
+
 export interface OtlpRelatedMetrics {
   context?: OtlpMetricsConsole['context'];
   filter?: string | null;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to