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 7d11789354 Support trace span attribute filters
7d11789354 is described below

commit 7d11789354e6c193f130103f7054618936097e51
Author: Logic <[email protected]>
AuthorDate: Tue Jun 9 21:51:24 2026 +0800

    Support trace span attribute filters
---
 .../traces/controller/TraceQueryController.java    |  10 +-
 .../traces/service/EntityTraceQueryService.java    |  29 ++++++
 .../service/impl/EntityTraceQueryServiceImpl.java  | 104 ++++++++++++++++++++-
 .../controller/TraceQueryControllerTest.java       |  21 +++--
 .../impl/EntityTraceQueryServiceImplTest.java      |  49 ++++++++++
 5 files changed, 195 insertions(+), 18 deletions(-)

diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java
index 3a18a62e8f..51e15e4ee5 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryController.java
@@ -62,6 +62,7 @@ public class TraceQueryController {
             @RequestParam(value = "serviceNamespace", required = false) String 
serviceNamespace,
             @RequestParam(value = "environment", required = false) String 
environment,
             @RequestParam(value = "resourceFilter", required = false) String 
resourceFilter,
+            @RequestParam(value = "attributeFilter", required = false) String 
attributeFilter,
             @RequestParam(value = "operationName", required = false) String 
operationName,
             @RequestParam(value = "minDurationMs", required = false) Long 
minDurationMs,
             @RequestParam(value = "maxDurationMs", required = false) Long 
maxDurationMs,
@@ -73,7 +74,7 @@ public class TraceQueryController {
         Page<TraceListItemDto> page = entityTraceQueryService.queryTraceList(
                 entityId, start, end, traceId, errorOnly, serviceName, 
serviceNamespace, environment,
                 scopedResourceFilter, operationName, minDurationMs, 
maxDurationMs, pageIndex, pageSize, hideInternal,
-                spanScope);
+                spanScope, attributeFilter);
         return ResponseEntity.ok(Message.success(page));
     }
 
@@ -90,6 +91,7 @@ public class TraceQueryController {
             @RequestParam(value = "serviceNamespace", required = false) String 
serviceNamespace,
             @RequestParam(value = "environment", required = false) String 
environment,
             @RequestParam(value = "resourceFilter", required = false) String 
resourceFilter,
+            @RequestParam(value = "attributeFilter", required = false) String 
attributeFilter,
             @RequestParam(value = "operationName", required = false) String 
operationName,
             @RequestParam(value = "minDurationMs", required = false) Long 
minDurationMs,
             @RequestParam(value = "maxDurationMs", required = false) Long 
maxDurationMs,
@@ -98,7 +100,8 @@ public class TraceQueryController {
         String scopedResourceFilter = 
mergeEntityContextResourceFilter(entityId, entityType, resourceFilter);
         return 
ResponseEntity.ok(Message.success(entityTraceQueryService.getTraceOverview(
                 entityId, start, end, traceId, errorOnly, serviceName, 
serviceNamespace, environment,
-                scopedResourceFilter, operationName, minDurationMs, 
maxDurationMs, hideInternal, spanScope)));
+                scopedResourceFilter, operationName, minDurationMs, 
maxDurationMs, hideInternal, spanScope,
+                attributeFilter)));
     }
 
     @GetMapping("/stats/group-by")
@@ -114,6 +117,7 @@ public class TraceQueryController {
             @RequestParam(value = "serviceNamespace", required = false) String 
serviceNamespace,
             @RequestParam(value = "environment", required = false) String 
environment,
             @RequestParam(value = "resourceFilter", required = false) String 
resourceFilter,
+            @RequestParam(value = "attributeFilter", required = false) String 
attributeFilter,
             @RequestParam(value = "operationName", required = false) String 
operationName,
             @RequestParam(value = "minDurationMs", required = false) Long 
minDurationMs,
             @RequestParam(value = "maxDurationMs", required = false) Long 
maxDurationMs,
@@ -127,7 +131,7 @@ public class TraceQueryController {
         return 
ResponseEntity.ok(Message.success(entityTraceQueryService.getTraceGroupByStats(
                 entityId, start, end, traceId, errorOnly, serviceName, 
serviceNamespace, environment,
                 scopedResourceFilter, operationName, minDurationMs, 
maxDurationMs, groupBy, limit, orderBy, minCount,
-                hideInternal, spanScope)));
+                hideInternal, spanScope, attributeFilter)));
     }
 
     @GetMapping("/{traceId}")
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/EntityTraceQueryService.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/EntityTraceQueryService.java
index 0a5fb0542b..339fea9cc3 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/EntityTraceQueryService.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/EntityTraceQueryService.java
@@ -63,6 +63,16 @@ public interface EntityTraceQueryService {
                                                   String resourceFilter, 
String operationName, Long minDurationMs,
                                                   Long maxDurationMs, int 
pageIndex, int pageSize, Boolean hideInternal,
                                                   String spanScope) {
+        return queryTraceList(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace, environment,
+                resourceFilter, operationName, minDurationMs, maxDurationMs, 
pageIndex, pageSize, hideInternal,
+                spanScope, null);
+    }
+
+    default Page<TraceListItemDto> queryTraceList(Long entityId, Long start, 
Long end, String traceId, Boolean errorOnly,
+                                                  String serviceName, String 
serviceNamespace, String environment,
+                                                  String resourceFilter, 
String operationName, Long minDurationMs,
+                                                  Long maxDurationMs, int 
pageIndex, int pageSize, Boolean hideInternal,
+                                                  String spanScope, String 
attributeFilter) {
         return queryTraceList(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace, environment,
                 resourceFilter, operationName, minDurationMs, maxDurationMs, 
pageIndex, pageSize, hideInternal);
     }
@@ -92,6 +102,14 @@ public interface EntityTraceQueryService {
                                               String serviceName, String 
serviceNamespace, String environment,
                                               String resourceFilter, String 
operationName, Long minDurationMs, Long maxDurationMs,
                                               Boolean hideInternal, String 
spanScope) {
+        return getTraceOverview(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace, environment,
+                resourceFilter, operationName, minDurationMs, maxDurationMs, 
hideInternal, spanScope, null);
+    }
+
+    default TraceOverviewDto getTraceOverview(Long entityId, Long start, Long 
end, String traceId, Boolean errorOnly,
+                                              String serviceName, String 
serviceNamespace, String environment,
+                                              String resourceFilter, String 
operationName, Long minDurationMs, Long maxDurationMs,
+                                              Boolean hideInternal, String 
spanScope, String attributeFilter) {
         return getTraceOverview(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace, environment,
                 resourceFilter, operationName, minDurationMs, maxDurationMs, 
hideInternal);
     }
@@ -107,6 +125,17 @@ public interface EntityTraceQueryService {
                                                      String resourceFilter, 
String operationName, Long minDurationMs,
                                                      Long maxDurationMs, 
String groupBy, Integer limit, String orderBy,
                                                      Integer minCount, Boolean 
hideInternal, String spanScope) {
+        return getTraceGroupByStats(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace,
+                environment, resourceFilter, operationName, minDurationMs, 
maxDurationMs, groupBy, limit, orderBy,
+                minCount, hideInternal, spanScope, null);
+    }
+
+    default Map<String, Object> getTraceGroupByStats(Long entityId, Long 
start, Long end, String traceId, Boolean errorOnly,
+                                                     String serviceName, 
String serviceNamespace, String environment,
+                                                     String resourceFilter, 
String operationName, Long minDurationMs,
+                                                     Long maxDurationMs, 
String groupBy, Integer limit, String orderBy,
+                                                     Integer minCount, Boolean 
hideInternal, String spanScope,
+                                                     String attributeFilter) {
         return getTraceGroupByStats(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace,
                 environment, resourceFilter, operationName, minDurationMs, 
maxDurationMs, groupBy, limit, orderBy,
                 minCount, hideInternal);
diff --git 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
index 2684d038eb..eb3b4195bd 100644
--- 
a/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
+++ 
b/hertzbeat-observability/src/main/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImpl.java
@@ -242,11 +242,23 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
                                                  String resourceFilter, String 
operationName, Long minDurationMs,
                                                  Long maxDurationMs, int 
pageIndex, int pageSize,
                                                  Boolean hideInternal, String 
spanScope) {
+        return queryTraceList(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace, environment,
+                resourceFilter, operationName, minDurationMs, maxDurationMs, 
pageIndex, pageSize, hideInternal,
+                spanScope, null);
+    }
+
+    @Override
+    public Page<TraceListItemDto> queryTraceList(Long entityId, Long start, 
Long end, String traceId, Boolean errorOnly,
+                                                 String serviceName, String 
serviceNamespace, String environment,
+                                                 String resourceFilter, String 
operationName, Long minDurationMs,
+                                                 Long maxDurationMs, int 
pageIndex, int pageSize,
+                                                 Boolean hideInternal, String 
spanScope, String attributeFilter) {
         ObservedEntityContext entityContext = entityId == null ? null : 
loadEntityContext(entityId);
         Map<String, Set<String>> identityValues = 
canonicalIdentityValues(entityContext);
         TraceQueryScope queryScope = resolveTraceQueryScope(entityContext, 
identityValues, serviceName, serviceNamespace, environment);
         ResourceFilterSet resourceFilters = removeEntityScopeResourceFilters(
                 identityValues, parseResourceFilters(resourceFilter));
+        ResourceFilterSet attributeFilters = 
parseResourceFilters(attributeFilter);
         Map<String, Set<String>> pushedResourceFilters = 
mergeResourceFilters(identityValues, resourceFilters.pushableInclude());
         PageRequest pageRequest = 
PageRequest.of(normalizeTraceListPageIndex(pageIndex), 
normalizeTraceListPageSize(pageSize));
         int repositoryOffset = 
Math.toIntExact(Math.min(pageRequest.getOffset(), Integer.MAX_VALUE));
@@ -254,6 +266,7 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
         Long maxDurationNanos = durationMillisToNanos(maxDurationMs);
         String normalizedSpanScope = normalizeSpanScope(spanScope);
         if (!StringUtils.hasText(traceId) && 
!resourceFilters.requiresRowFallback()
+                && attributeFilters.isEmpty()
                 && traceQueryRepository.supportsTraceListRows()) {
             List<Map<String, Object>> rows = 
StringUtils.hasText(normalizedSpanScope)
                     ? traceQueryRepository.queryTraceListRows(
@@ -305,7 +318,7 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
                 .filter(trace -> matchesSpanScope(trace, normalizedSpanScope))
                 .filter(trace -> matchesTraceFilters(trace, identityValues, 
resourceFilters, start, end, traceId, errorOnly,
                         queryScope.serviceName(), 
queryScope.serviceNamespace(), queryScope.environment(), operationName,
-                        minDurationNanos, maxDurationNanos, hideInternal))
+                        minDurationNanos, maxDurationNanos, hideInternal, 
attributeFilters))
                 .sorted(Comparator.comparing(TraceAggregate::getStartTime, 
Comparator.nullsLast(Comparator.reverseOrder())))
                 .toList();
         int safeStart = Math.min(repositoryOffset, filtered.size());
@@ -376,16 +389,27 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
                                              String serviceName, String 
serviceNamespace, String environment,
                                              String resourceFilter, String 
operationName, Long minDurationMs, Long maxDurationMs,
                                              Boolean hideInternal, String 
spanScope) {
+        return getTraceOverview(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace, environment,
+                resourceFilter, operationName, minDurationMs, maxDurationMs, 
hideInternal, spanScope, null);
+    }
+
+    @Override
+    public TraceOverviewDto getTraceOverview(Long entityId, Long start, Long 
end, String traceId, Boolean errorOnly,
+                                             String serviceName, String 
serviceNamespace, String environment,
+                                             String resourceFilter, String 
operationName, Long minDurationMs, Long maxDurationMs,
+                                             Boolean hideInternal, String 
spanScope, String attributeFilter) {
         ObservedEntityContext entityContext = entityId == null ? null : 
loadEntityContext(entityId);
         Map<String, Set<String>> identityValues = 
canonicalIdentityValues(entityContext);
         TraceQueryScope queryScope = resolveTraceQueryScope(entityContext, 
identityValues, serviceName, serviceNamespace, environment);
         ResourceFilterSet resourceFilters = removeEntityScopeResourceFilters(
                 identityValues, parseResourceFilters(resourceFilter));
+        ResourceFilterSet attributeFilters = 
parseResourceFilters(attributeFilter);
         Map<String, Set<String>> pushedResourceFilters = 
mergeResourceFilters(identityValues, resourceFilters.pushableInclude());
         Long minDurationNanos = durationMillisToNanos(minDurationMs);
         Long maxDurationNanos = durationMillisToNanos(maxDurationMs);
         String normalizedSpanScope = normalizeSpanScope(spanScope);
         if (StringUtils.hasText(traceId) && 
!resourceFilters.requiresRowFallback()
+                && attributeFilters.isEmpty()
                 && traceQueryRepository.supportsTraceIdOverviewRows()) {
             Map<String, Object> row = StringUtils.hasText(normalizedSpanScope)
                     ? traceQueryRepository.queryTraceIdOverviewRows(
@@ -425,6 +449,7 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
             }
         }
         if (!StringUtils.hasText(traceId) && 
!resourceFilters.requiresRowFallback()
+                && attributeFilters.isEmpty()
                 && traceQueryRepository.supportsTraceOverviewRows()) {
             Map<String, Object> row = StringUtils.hasText(normalizedSpanScope)
                     ? traceQueryRepository.queryTraceOverviewRows(
@@ -464,7 +489,7 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
         Page<TraceListItemDto> result = queryTraceList(entityId, start, end, 
traceId, errorOnly,
                 queryScope.serviceName(), queryScope.serviceNamespace(), 
queryScope.environment(),
                 resourceFilter, operationName, minDurationMs, maxDurationMs, 
0, TRACE_LIST_SAMPLE_LIMIT, hideInternal,
-                normalizedSpanScope);
+                normalizedSpanScope, attributeFilter);
         Long latestObservedAt = result.getContent().stream()
                 .map(TraceListItemDto::getStartTime)
                 .filter(Objects::nonNull)
@@ -493,6 +518,18 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
                                                     Long minDurationMs, Long 
maxDurationMs, String groupBy,
                                                     Integer limit, String 
orderBy, Integer minCount, Boolean hideInternal,
                                                     String spanScope) {
+        return getTraceGroupByStats(entityId, start, end, traceId, errorOnly, 
serviceName, serviceNamespace,
+                environment, resourceFilter, operationName, minDurationMs, 
maxDurationMs, groupBy, limit, orderBy,
+                minCount, hideInternal, spanScope, null);
+    }
+
+    @Override
+    public Map<String, Object> getTraceGroupByStats(Long entityId, Long start, 
Long end, String traceId,
+                                                    Boolean errorOnly, String 
serviceName, String serviceNamespace,
+                                                    String environment, String 
resourceFilter, String operationName,
+                                                    Long minDurationMs, Long 
maxDurationMs, String groupBy,
+                                                    Integer limit, String 
orderBy, Integer minCount, Boolean hideInternal,
+                                                    String spanScope, String 
attributeFilter) {
         String normalizedGroupBy = normalizeTraceGroupBy(groupBy);
         int resolvedLimit = resolveTraceGroupByLimit(limit);
         long resolvedMinCount = resolveTraceGroupByMinCount(minCount);
@@ -508,10 +545,12 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
         TraceQueryScope queryScope = resolveTraceQueryScope(entityContext, 
identityValues, serviceName, serviceNamespace, environment);
         ResourceFilterSet resourceFilters = removeEntityScopeResourceFilters(
                 identityValues, parseResourceFilters(resourceFilter));
+        ResourceFilterSet attributeFilters = 
parseResourceFilters(attributeFilter);
         Map<String, Set<String>> pushedResourceFilters = 
mergeResourceFilters(identityValues, resourceFilters.pushableInclude());
         Long minDurationNanos = durationMillisToNanos(minDurationMs);
         Long maxDurationNanos = durationMillisToNanos(maxDurationMs);
         if (!StringUtils.hasText(traceId) && 
!resourceFilters.requiresRowFallback()
+                && attributeFilters.isEmpty()
                 && traceQueryRepository.supportsTraceGroupByRows()) {
             List<Map<String, Object>> rows = 
StringUtils.hasText(normalizedSpanScope)
                     ? traceQueryRepository.queryTraceGroupByRows(
@@ -556,7 +595,7 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
         }
         Page<TraceListItemDto> traces = queryTraceList(entityId, start, end, 
traceId, errorOnly, queryScope.serviceName(),
                 queryScope.serviceNamespace(), queryScope.environment(), 
resourceFilter, operationName, minDurationMs, maxDurationMs,
-                0, TRACE_LIST_SAMPLE_LIMIT, hideInternal, normalizedSpanScope);
+                0, TRACE_LIST_SAMPLE_LIMIT, hideInternal, normalizedSpanScope, 
attributeFilter);
         result.put("groups", buildTraceGroupResults(traces.getContent(), 
normalizedGroupBy, resolvedLimit, orderBy, resolvedMinCount));
         return result;
     }
@@ -921,7 +960,8 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
                                         ResourceFilterSet resourceFilters, 
Long start, Long end,
                                         String traceId, Boolean errorOnly, 
String serviceName, String serviceNamespace,
                                         String environment, String 
operationName, Long minDurationNanos,
-                                        Long maxDurationNanos, Boolean 
hideInternal) {
+                                        Long maxDurationNanos, Boolean 
hideInternal,
+                                        ResourceFilterSet attributeFilters) {
         if (trace == null) {
             return false;
         }
@@ -964,7 +1004,8 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
         if (!identityValues.isEmpty() && !matchesEntity(trace, 
identityValues)) {
             return false;
         }
-        return resourceFilters.isEmpty() || matchesResourceFilters(trace, 
resourceFilters);
+        return (resourceFilters.isEmpty() || matchesResourceFilters(trace, 
resourceFilters))
+                && matchesSpanAttributeFilters(trace, attributeFilters);
     }
 
     private String normalizeSpanScope(String spanScope) {
@@ -1061,6 +1102,59 @@ public class EntityTraceQueryServiceImpl implements 
EntityTraceQueryService {
                 && matchesExcludedResourceFilters(trace, 
resourceFilters.exclude());
     }
 
+    private boolean matchesSpanAttributeFilters(TraceAggregate trace, 
ResourceFilterSet attributeFilters) {
+        if (attributeFilters == null || attributeFilters.isEmpty()) {
+            return true;
+        }
+        if (trace == null || CollectionUtils.isEmpty(trace.spans)) {
+            return false;
+        }
+        return trace.spans.stream()
+                .anyMatch(span -> matchesFilterMap(span.getSpanAttributes(), 
attributeFilters));
+    }
+
+    private boolean matchesFilterMap(Map<String, String> values, 
ResourceFilterSet filters) {
+        Map<String, String> source = values == null ? Collections.emptyMap() : 
values;
+        return matchesIncludedFilterMap(source, filters.include())
+                && matchesExcludedFilterMap(source, filters.exclude());
+    }
+
+    private boolean matchesIncludedFilterMap(Map<String, String> source, 
Map<String, Set<String>> filters) {
+        if (filters.isEmpty()) {
+            return true;
+        }
+        for (Map.Entry<String, Set<String>> entry : filters.entrySet()) {
+            String actual = trimText(source.get(entry.getKey()));
+            boolean keyExists = source.containsKey(entry.getKey());
+            boolean matched = entry.getValue().stream()
+                    .filter(StringUtils::hasText)
+                    .anyMatch(expected -> matchesResourceFilterValue(actual, 
expected, keyExists));
+            if (!matched) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private boolean matchesExcludedFilterMap(Map<String, String> source, 
Map<String, Set<String>> filters) {
+        if (filters.isEmpty()) {
+            return true;
+        }
+        for (Map.Entry<String, Set<String>> entry : filters.entrySet()) {
+            String actual = trimText(source.get(entry.getKey()));
+            if (!StringUtils.hasText(actual)) {
+                continue;
+            }
+            boolean excluded = entry.getValue().stream()
+                    .filter(StringUtils::hasText)
+                    .anyMatch(expected -> 
matchesExactResourceFilterValue(actual, expected));
+            if (excluded) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private boolean matchesIncludedResourceFilters(TraceAggregate trace, 
Map<String, Set<String>> resourceFilters) {
         if (resourceFilters.isEmpty()) {
             return true;
diff --git 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java
 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java
index f5ab96e167..4b8f92fbea 100644
--- 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java
+++ 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/controller/TraceQueryControllerTest.java
@@ -69,7 +69,7 @@ class TraceQueryControllerTest {
         when(entityTraceQueryService.queryTraceList(
                 1L, 100L, 200L, "trace-1", true, "checkout", "commerce", 
"prod",
                 "service.version=1.2.3 and hertzbeat.entity_id=\"1\" and 
hertzbeat.entity_type=\"service\"", "GET /checkout",
-                100L, 500L, 2, 50, true, null))
+                100L, 500L, 2, 50, true, null, "http.route CONTAINS checkout"))
                 .thenReturn(new PageImpl<>(List.of(item), PageRequest.of(2, 
50), 1));
 
         mockMvc.perform(get("/api/traces/list")
@@ -83,6 +83,7 @@ class TraceQueryControllerTest {
                         .param("serviceNamespace", "commerce")
                         .param("environment", "prod")
                         .param("resourceFilter", "service.version=1.2.3")
+                        .param("attributeFilter", "http.route CONTAINS 
checkout")
                         .param("operationName", "GET /checkout")
                         .param("minDurationMs", "100")
                         .param("maxDurationMs", "500")
@@ -97,7 +98,7 @@ class TraceQueryControllerTest {
         verify(entityTraceQueryService).queryTraceList(
                 1L, 100L, 200L, "trace-1", true, "checkout", "commerce", 
"prod",
                 "service.version=1.2.3 and hertzbeat.entity_id=\"1\" and 
hertzbeat.entity_type=\"service\"", "GET /checkout",
-                100L, 500L, 2, 50, true, null);
+                100L, 500L, 2, 50, true, null, "http.route CONTAINS checkout");
     }
 
     @Test
@@ -116,7 +117,7 @@ class TraceQueryControllerTest {
         );
         when(entityTraceQueryService.queryTraceList(
                 null, 100L, 200L, null, false, "checkout", null, "prod",
-                null, "POST /checkout", 100L, 500L, 0, 20, null, "entrypoint"))
+                null, "POST /checkout", 100L, 500L, 0, 20, null, "entrypoint", 
null))
                 .thenReturn(new PageImpl<>(List.of(item), PageRequest.of(0, 
20), 1));
 
         mockMvc.perform(get("/api/traces/list")
@@ -135,7 +136,7 @@ class TraceQueryControllerTest {
 
         verify(entityTraceQueryService).queryTraceList(
                 null, 100L, 200L, null, false, "checkout", null, "prod",
-                null, "POST /checkout", 100L, 500L, 0, 20, null, "entrypoint");
+                null, "POST /checkout", 100L, 500L, 0, 20, null, "entrypoint", 
null);
     }
 
     @Test
@@ -143,7 +144,7 @@ class TraceQueryControllerTest {
         TraceOverviewDto overview = new TraceOverviewDto(2, 1, 
1_710_000_000_000L, true);
         when(entityTraceQueryService.getTraceOverview(
                 9L, 100L, 200L, "trace-9", false, "payments", "core", "stage",
-                "service.version=2.0.0 and hertzbeat.entity_id=\"9\"", "POST 
/pay", 200L, 900L, true, null))
+                "service.version=2.0.0 and hertzbeat.entity_id=\"9\"", "POST 
/pay", 200L, 900L, true, null, null))
                 .thenReturn(overview);
 
         mockMvc.perform(get("/api/traces/stats/overview")
@@ -168,7 +169,7 @@ class TraceQueryControllerTest {
 
         verify(entityTraceQueryService).getTraceOverview(
                 9L, 100L, 200L, "trace-9", false, "payments", "core", "stage",
-                "service.version=2.0.0 and hertzbeat.entity_id=\"9\"", "POST 
/pay", 200L, 900L, true, null);
+                "service.version=2.0.0 and hertzbeat.entity_id=\"9\"", "POST 
/pay", 200L, 900L, true, null, null);
     }
 
     @Test
@@ -176,7 +177,7 @@ class TraceQueryControllerTest {
         TraceOverviewDto overview = new TraceOverviewDto(3, 0, 
1_710_000_000_000L, true);
         when(entityTraceQueryService.getTraceOverview(
                 null, 100L, 200L, null, false, "checkout", null, "prod",
-                null, "POST /checkout", 100L, 500L, null, "entrypoint"))
+                null, "POST /checkout", 100L, 500L, null, "entrypoint", null))
                 .thenReturn(overview);
 
         mockMvc.perform(get("/api/traces/stats/overview")
@@ -195,7 +196,7 @@ class TraceQueryControllerTest {
 
         verify(entityTraceQueryService).getTraceOverview(
                 null, 100L, 200L, null, false, "checkout", null, "prod",
-                null, "POST /checkout", 100L, 500L, null, "entrypoint");
+                null, "POST /checkout", 100L, 500L, null, "entrypoint", null);
     }
 
     @Test
@@ -213,7 +214,7 @@ class TraceQueryControllerTest {
         when(entityTraceQueryService.getTraceGroupByStats(
                 3L, 100L, 200L, "trace-3", true, "checkout", "commerce", 
"prod",
                 "host.name=checkout-1 and hertzbeat.entity_id=\"3\"", "GET 
/checkout", 100L, 500L,
-                "resource:service.version", 7, "latency-p95-desc", 5, true, 
"entrypoint"))
+                "resource:service.version", 7, "latency-p95-desc", 5, true, 
"entrypoint", null))
                 .thenReturn(result);
 
         mockMvc.perform(get("/api/traces/stats/group-by")
@@ -247,6 +248,6 @@ class TraceQueryControllerTest {
         verify(entityTraceQueryService).getTraceGroupByStats(
                 3L, 100L, 200L, "trace-3", true, "checkout", "commerce", 
"prod",
                 "host.name=checkout-1 and hertzbeat.entity_id=\"3\"", "GET 
/checkout", 100L, 500L,
-                "resource:service.version", 7, "latency-p95-desc", 5, true, 
"entrypoint");
+                "resource:service.version", 7, "latency-p95-desc", 5, true, 
"entrypoint", null);
     }
 }
diff --git 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java
 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java
index 7e8f4cfe07..7129c681de 100644
--- 
a/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java
+++ 
b/hertzbeat-observability/src/test/java/org/apache/hertzbeat/observability/traces/service/impl/EntityTraceQueryServiceImplTest.java
@@ -688,6 +688,55 @@ class EntityTraceQueryServiceImplTest {
                 org.mockito.ArgumentMatchers.anyInt());
     }
 
+    @Test
+    void queryTraceListAppliesSpanAttributeFiltersWithRowFallback() {
+        long now = System.currentTimeMillis();
+        long start = now - 120_000;
+        long end = now;
+        Map<String, Object> matchingRow = traceRow("trace-checkout", 
"span-root-1", null, "GET /checkout",
+                "checkout-service", "STATUS_CODE_OK", now - 10_000, 2_000_000L,
+                Map.of("service.name", "checkout-service", "service.version", 
"1.2.3"));
+        matchingRow.put("span_attributes", Map.of("http.route", 
"/checkout/{id}", "span.kind", "server"));
+        Map<String, Object> inventoryRow = traceRow("trace-inventory", 
"span-root-2", null, "GET /inventory",
+                "checkout-service", "STATUS_CODE_OK", now - 9_000, 2_000_000L,
+                Map.of("service.name", "checkout-service", "service.version", 
"1.2.3"));
+        inventoryRow.put("span_attributes", Map.of("http.route", "/inventory", 
"span.kind", "server"));
+        Map<String, Object> databaseRow = traceRow("trace-db", "span-root-3", 
null, "GET /checkout",
+                "checkout-service", "STATUS_CODE_OK", now - 8_000, 2_000_000L,
+                Map.of("service.name", "checkout-service", "service.version", 
"1.2.3"));
+        databaseRow.put("span_attributes", Map.of("http.route", 
"/checkout/{id}", "db.system", "mysql"));
+        when(traceQueryRepository.queryRecentTraceRows(
+                eq(1500), eq(start), eq(end), eq("checkout-service"), 
org.mockito.ArgumentMatchers.isNull(),
+                org.mockito.ArgumentMatchers.isNull(), 
org.mockito.ArgumentMatchers.isNull(),
+                org.mockito.ArgumentMatchers.isNull(), 
org.mockito.ArgumentMatchers.isNull(),
+                org.mockito.ArgumentMatchers.isNull(), 
org.mockito.ArgumentMatchers.<Map<String, Set<String>>>any(),
+                eq(false))).thenReturn(List.of(matchingRow, inventoryRow, 
databaseRow));
+
+        var page = entityTraceQueryService.queryTraceList(null, start, end, 
null,
+                false, "checkout-service", null, null,
+                "service.version=1.2.3", null, null, null, 0, 20, false, null,
+                "http.route CONTAINS checkout and db.system NOT EXISTS");
+
+        assertEquals(1, page.getTotalElements());
+        assertEquals("trace-checkout", 
page.getContent().getFirst().getTraceId());
+        ArgumentCaptor<Map<String, Set<String>>> pushedFilterCaptor = 
ArgumentCaptor.forClass(Map.class);
+        verify(traceQueryRepository).queryRecentTraceRows(
+                eq(1500), eq(start), eq(end), eq("checkout-service"), 
org.mockito.ArgumentMatchers.isNull(),
+                org.mockito.ArgumentMatchers.isNull(), 
org.mockito.ArgumentMatchers.isNull(),
+                org.mockito.ArgumentMatchers.isNull(), 
org.mockito.ArgumentMatchers.isNull(),
+                org.mockito.ArgumentMatchers.isNull(), 
pushedFilterCaptor.capture(), eq(false));
+        assertEquals(Map.of("service.version", Set.of("1.2.3")), 
pushedFilterCaptor.getValue());
+        verify(traceQueryRepository, never()).queryTraceListRows(
+                org.mockito.ArgumentMatchers.any(), 
org.mockito.ArgumentMatchers.any(),
+                org.mockito.ArgumentMatchers.any(), 
org.mockito.ArgumentMatchers.any(),
+                org.mockito.ArgumentMatchers.any(), 
org.mockito.ArgumentMatchers.any(),
+                org.mockito.ArgumentMatchers.any(), 
org.mockito.ArgumentMatchers.any(),
+                org.mockito.ArgumentMatchers.any(), 
org.mockito.ArgumentMatchers.any(),
+                org.mockito.ArgumentMatchers.<Map<String, Set<String>>>any(),
+                org.mockito.ArgumentMatchers.any(), 
org.mockito.ArgumentMatchers.anyInt(),
+                org.mockito.ArgumentMatchers.anyInt());
+    }
+
     @Test
     void traceQueriesPreferEntityIdentityOverConflictingRouteContext() {
         long now = System.currentTimeMillis();


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


Reply via email to