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]