diff --git a/astra/src/main/java/com/slack/astra/graphApi/GraphBuilder.java b/astra/src/main/java/com/slack/astra/graphApi/GraphBuilder.java index 008574dd44..564a0e10d9 100644 --- a/astra/src/main/java/com/slack/astra/graphApi/GraphBuilder.java +++ b/astra/src/main/java/com/slack/astra/graphApi/GraphBuilder.java @@ -1,11 +1,14 @@ package com.slack.astra.graphApi; import com.slack.astra.zipkinApi.ZipkinSpanResponse; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -15,11 +18,11 @@ * GraphBuilder constructs service dependency graphs from Zipkin span data. * *

This class processes distributed tracing spans to build a graph representation showing - * relationships between services. It creates nodes representing services and edges representing - * parent-child relationships between spans. + * relationships between operations. It creates nodes representing service operations and edges + * representing parent-child relationships between spans. * - *

The builder supports configurable node metadata extraction through GraphConfig, allowing - * customization of which span tags are used to populate node metadata. + *

The builder supports configurable node and edge metadata extraction through GraphConfig, + * allowing customization of which span tags are used to populate each entity's metadata. */ public class GraphBuilder { private static final Logger LOG = LoggerFactory.getLogger(GraphBuilder.class); @@ -36,61 +39,195 @@ public class GraphBuilder { } /** - * Builds a dependency graph from a list of Zipkin spans. + * Filter for selecting nodes/edges in the graph based on metadata criteria. * - *

This method processes spans to create nodes (services) and edges (dependencies) representing - * the service communication graph. Each span becomes a node, and parent-child relationships - * between spans become edges in the graph. Logs warnings for any missing parent or child nodes. + *

The filter uses OR logic: a span matches if ANY of the filter criteria match. Each filter + * option is a field name (e.g., "operation", "service") mapped to a list of allowed values for + * that field. + * + *

Examples: {"operation": ["http.request"]} - matches spans with tag operation="http.request" + * {"operation": ["http.request", "grpc.request"]} - matches spans with either operation tag + * {"operation": ["http.request"], "kube.namespace": ["test-app-prod"]} - matches spans with + * operation="http.request" OR kube.namespace="test-app-prod" {} or null - empty filter matches + * all spans (no filtering) + * + * @param options Map of field names to lists of allowed values. If null or empty, all spans + * match. + */ + public record Filter(Map> options) { + public boolean matches(ZipkinSpanResponse span) { + // Empty or null filter means match all spans + if (options == null || options.isEmpty()) { + return true; + } + + // Returns true if ANY filter matches + return options.entrySet().stream() + .filter(entry -> entry.getValue() != null && !entry.getValue().isEmpty()) + .anyMatch( + entry -> { + String actualValue = span.getTags().get(entry.getKey()); + return actualValue != null && entry.getValue().contains(actualValue); + }); + } + } + + /** + * Builds an (optionally filtered) dependency graph from a list of Zipkin spans. + * + *

This method processes spans to create nodes and edges representing operation dependencies, + * filtered by any specified criteria. If a filter is specified, it collects all filtered spans + * first, then for each filtered span, finds its transitive matching children (filtered spans + * reachable through non-filtered intermediate spans) and creates edges between them. * * @param spans List of Zipkin spans to process - * @return Graph containing nodes and edges representing service dependencies + * @param filter Optional filter to apply when building the graph. If empty or null, returns every + * connection. + * @return Graph containing nodes and edges representing operation dependencies */ - public Graph buildFromSpans(List spans) { - // First pass: build mapping between spanId -> Node - Map spanIdToNode = - spans.stream() - .filter(span -> span.getId() != null) - .collect(Collectors.toMap(ZipkinSpanResponse::getId, this::createChildNodeFromSpan)); - - // Second pass: build unique edges - Set edges = - spans.stream() - .filter(span -> span.getId() != null && span.getParentId() != null) + public Graph buildFromSpans(List spans, Optional filter) { + // Build all lookup structures + Map spanIdToSpan = new HashMap<>(); // Lookup a span by span ID + Map spanIdToNode = new HashMap<>(); // Lookup a span's logical node by span ID + Map nodeIdToNode = new HashMap<>(); // Lookup a node by node ID + + // Convert spans to nodes, creating logical groupings. + // Multiple spans may map to the same logical node if their metadata is identical. + spans.stream() + .filter(span -> span.getId() != null) + .forEach( + span -> { + spanIdToSpan.put(span.getId(), span); + + Node node = + new Node(config.createMetadataFromSpan(span, GraphConfig.EntityType.NODE)); + spanIdToNode.put(span.getId(), node); + nodeIdToNode.putIfAbsent(node.getId(), node); + }); + + // Build parent-child relationships at the node level + Map>> parentNodeIdToChildNodeIds = + buildParentChildConnections(spans, spanIdToNode); + + // Determine which nodes to include based on the filter if provided, otherwise include all nodes + Set nodesToProcess = + filter .map( - span -> { - Node parentNode = spanIdToNode.get(span.getParentId()); - Node childNode = spanIdToNode.get(span.getId()); - - if (parentNode != null && childNode != null) { - return new Edge( - parentNode.getId(), - childNode.getId(), - config.createMetadataFromSpan(span, GraphConfig.EntityType.EDGE)); - } else { - LOG.warn( - "Missing parent or child node for parentSpanId={} and childSpanId={}", - span.getParentId(), - span.getId()); - return null; - } - }) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - - // Dedupe nodes - Set nodes = new HashSet<>(spanIdToNode.values()); + f -> + spanIdToNode.keySet().stream() + .filter(spanId -> f.matches(spanIdToSpan.get(spanId))) + .map(spanId -> spanIdToNode.get(spanId).getId()) + .collect(Collectors.toSet())) + .orElseGet(() -> new HashSet<>(nodeIdToNode.keySet())); - return new Graph(new ArrayList<>(nodes), new ArrayList<>(edges)); + return traverseAndBuildGraph(filter, nodesToProcess, nodeIdToNode, parentNodeIdToChildNodeIds); } /** - * Creates a Node from a Zipkin span using configured metadata extraction. Calls out to the - * config's createMetadataFromSpan function to generate node metadata from a span. + * Builds a map of parent-child relationships at the node level. + * + *

This method aggregates span relationships into node relationships. Multiple spans may + * represent the same logical node, so this aggregation is crucial for handling siblings. For + * example, if span S1 and S2 both map to node A, and S1 has child S3 (node B) while S2 has child + * S4 (node C), the result will be: node A -> [node B, node C]. * - * @param span The Zipkin span to convert to a node - * @return Node with metadata extracted from the span + *

Edge metadata is preserved from the original span connection, representing the actual traced + * operation that created the relationship. + * + * @param spans List of all spans to process + * @param spanIdToNode Map from span ID to its logical node representation + * @return Map from parent node ID to list of (child node ID, reference span) pairs */ - private Node createChildNodeFromSpan(ZipkinSpanResponse span) { - return new Node(config.createMetadataFromSpan(span, GraphConfig.EntityType.NODE)); + private Map>> buildParentChildConnections( + List spans, Map spanIdToNode) { + + Map>> parentNodeIdToChildNodeIds = + new HashMap<>(); + + for (ZipkinSpanResponse span : spans) { + if (span.getId() == null || span.getParentId() == null) continue; + + Node parent = spanIdToNode.get(span.getParentId()); + Node child = spanIdToNode.get(span.getId()); + if (parent == null || child == null) continue; + + // Keep a reference to the span that produced this edge. This is used later during traversal + // to decide which edges to retain when a filter is applied. Without it, if the filter depends + // on span tags that also define edge metadata, we could end up creating incorrect or + // missing relationships. + parentNodeIdToChildNodeIds + .computeIfAbsent(parent.getId(), k -> new ArrayList<>()) + .add(Map.entry(child.getId(), span)); + } + return parentNodeIdToChildNodeIds; + } + + /** + * Traverses the node graph to build the final filtered graph with transitive edges. + * + *

For each filtered node, this method performs a depth-first traversal to find all filtered + * descendant nodes, skipping through non-filtered intermediate nodes. When a filtered descendant + * is found, an edge is created directly from the starting filtered node to the descendant, + * preserving the edge metadata from the original connection path. + * + *

Example: If we have Root (filtered) -> Intermediate (not filtered) -> Leaf (filtered), this + * will create a direct edge: Root -> Leaf, skipping the intermediate node. + * + * @param filter Optional filter to apply when creating edges + * @param nodesToProcess Set of node IDs that match the filter (or all nodes if no filter) + * @param nodeIdToNode Map from node ID to Node object + * @param parentNodeIdToChildNodeIds Map of parent-child relationships with their reference span + * @return Graph containing only filtered nodes and their transitive connections + */ + private Graph traverseAndBuildGraph( + Optional filter, + Set nodesToProcess, + Map nodeIdToNode, + Map>> parentNodeIdToChildNodeIds) { + + Set nodes = new HashSet<>(); + Set edges = new HashSet<>(); + + // Process each filtered node as a potential parent + for (String parentNodeId : nodesToProcess) { + Deque work = new ArrayDeque<>(); + Set visitedNodes = new HashSet<>(); + + work.push(parentNodeId); + + while (!work.isEmpty()) { + String currentNodeId = work.pop(); + if (!visitedNodes.add(currentNodeId)) continue; + + List> children = + parentNodeIdToChildNodeIds.getOrDefault(currentNodeId, List.of()); + + for (Map.Entry child : children) { + String childNodeId = child.getKey(); + ZipkinSpanResponse refSpan = child.getValue(); + if (!filter.isPresent() || filter.get().matches(refSpan)) { + // Skip the case where the ancestor is a direct parent of the same logical node ID + if (parentNodeId.equals(childNodeId)) continue; + + // Found a child that matches the filter when present, create edge + // from starting parent to this child. + // This creates the transitive edge, skipping any intermediate nodes. + // Don't traverse past this child - it will be processed in its own iteration. + nodes.add(nodeIdToNode.get(parentNodeId)); + nodes.add(nodeIdToNode.get(childNodeId)); + edges.add( + new Edge( + parentNodeId, + childNodeId, + config.createMetadataFromSpan(refSpan, GraphConfig.EntityType.EDGE))); + } else { + // Non-filtered intermediate node - continue traversing through it + work.push(childNodeId); + } + } + } + } + + return new Graph(new ArrayList<>(nodes), new ArrayList<>(edges)); } } diff --git a/astra/src/main/java/com/slack/astra/graphApi/GraphConfig.java b/astra/src/main/java/com/slack/astra/graphApi/GraphConfig.java index 7905f731f5..080eda946d 100644 --- a/astra/src/main/java/com/slack/astra/graphApi/GraphConfig.java +++ b/astra/src/main/java/com/slack/astra/graphApi/GraphConfig.java @@ -31,25 +31,30 @@ public enum EntityType { /** * Represents how a single logical field on a node should be mapped to span tags. * - *

Each field has: - a default key to look up in tags - a default fallback value if the key - * isn’t found - an optional list of rules that can override the default key + *

Each field has: - default key (can be a list) to look up in tags (combined with delimiter if + * multiple) - a default fallback value if the keys aren't found - an optional delimiter for + * combining multiple key values - an optional list of rules that can override the default key */ public static final class TagConfig { - private final String defaultKey; + private final List defaultKey; private final String defaultValue; + private final String keyDelimiter; private final List rules; @JsonCreator public TagConfig( - @JsonProperty("default_key") String defaultKey, + @JsonProperty("default_key") List defaultKey, @JsonProperty("default_value") String defaultValue, + @JsonProperty("key_delimiter") String keyDelimiter, @JsonProperty("rules") List rules) { - this.defaultKey = defaultKey; + this.defaultKey = (defaultKey == null) ? Collections.emptyList() : List.copyOf(defaultKey); this.defaultValue = defaultValue; + // Set default keyDelimiter to "." if null or empty + this.keyDelimiter = (keyDelimiter == null || keyDelimiter.isEmpty()) ? "." : keyDelimiter; this.rules = (rules == null) ? Collections.emptyList() : List.copyOf(rules); } - public String getDefaultKey() { + public List getDefaultKey() { return defaultKey; } @@ -57,6 +62,10 @@ public String getDefaultValue() { return defaultValue; } + public String getKeyDelimiter() { + return keyDelimiter; + } + public List getRules() { return rules; } @@ -69,16 +78,16 @@ public List getRules() { public static class RuleConfig { private final String field; private final String value; - private final String overrideKey; + private final List overrideKey; @JsonCreator public RuleConfig( @JsonProperty("field") String field, @JsonProperty("value") String value, - @JsonProperty("override_key") String overrideKey) { + @JsonProperty("override_key") List overrideKey) { this.field = field; this.value = value; - this.overrideKey = overrideKey; + this.overrideKey = (overrideKey == null) ? Collections.emptyList() : List.copyOf(overrideKey); } public String getField() { @@ -89,7 +98,7 @@ public String getValue() { return value; } - public String getOverrideKey() { + public List getOverrideKey() { return overrideKey; } } @@ -200,10 +209,11 @@ public SortedMap createMetadataFromSpan( /** * Resolves the actual tag value for a given logical field, using the provided span tags. * - *

Steps: 1. Look up the TagConfig for this logical field (e.g. "resource"). 2. Default to - * using its defaultKey + defaultValue. 3. If rules are defined: - Iterate through each rule in - * reverse order. - If a rule's field/value condition matches, switch keyToUse to overrideKey. 4. - * Finally, look up the chosen key in tags. If missing, fall back to defaultValue. + *

Steps: 1. Look up the TagConfig for this logical field (e.g. "resource"). 2. Check if any + * rules match: - Iterate through each rule in reverse order. - If a rule's field/value condition + * matches, use the overrideKey to look up the value. 3. If no rule matches, use the defaultKey: - + * Look up each keyPart from defaultKey list in the tags map. - Combine the values with the + * delimiter if multiple parts are present. - If any part is missing, fall back to defaultValue. * *

Note: This logic does not currently support multiple field matches for a single rule. * @@ -225,18 +235,39 @@ public String resolve(Map tags, String logicalField, EntityType // Later rules override earlier ones, so start from the back of the list and use the first one // that matches. - String keyToUse = + RuleConfig matchingRule = baseCfg.getRules().reversed().stream() .filter( rule -> rule.getValue() .equals(tags.getOrDefault(rule.getField(), "unknown_" + rule.getField()))) - .map(RuleConfig::getOverrideKey) - .filter(tags::containsKey) .findFirst() - .orElse(baseCfg.getDefaultKey()); + .orElse(null); + + // Determine which key to use (override or default) + List keyToUse = + matchingRule != null ? matchingRule.getOverrideKey() : baseCfg.getDefaultKey(); + + if (keyToUse == null || keyToUse.isEmpty()) { + return baseCfg.getDefaultValue(); + } - return tags.getOrDefault(keyToUse, baseCfg.getDefaultValue()); + // Collect values for all parts in a key + List values = new java.util.ArrayList<>(); + for (String keyPart : keyToUse) { + String value = tags.get(keyPart); + if (value == null) { + // If any key is missing, return the default value + return baseCfg.getDefaultValue(); + } + values.add(value); + } + + // Combine values with delimiter if present and multiple keys exist + if (values.size() > 1 && baseCfg.getKeyDelimiter() != null) { + return String.join(baseCfg.getKeyDelimiter(), values); + } + return values.get(0); } @Override diff --git a/astra/src/main/java/com/slack/astra/graphApi/GraphService.java b/astra/src/main/java/com/slack/astra/graphApi/GraphService.java index 933bcb5e80..884bb63ab0 100644 --- a/astra/src/main/java/com/slack/astra/graphApi/GraphService.java +++ b/astra/src/main/java/com/slack/astra/graphApi/GraphService.java @@ -1,6 +1,7 @@ package com.slack.astra.graphApi; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; @@ -8,10 +9,15 @@ import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.annotation.Header; import com.linecorp.armeria.server.annotation.Param; import com.linecorp.armeria.server.annotation.Path; -import com.slack.astra.server.AstraQueryServiceBase; +import com.slack.astra.zipkinApi.TraceFetcher; +import com.slack.astra.zipkinApi.ZipkinSpanResponse; import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,8 +26,8 @@ */ public class GraphService { private static final Logger LOG = LoggerFactory.getLogger(GraphService.class); - private final AstraQueryServiceBase searcher; - private final GraphConfig graphConfig; + private final TraceFetcher traceFetcher; + private final GraphBuilder graphBuilder; private static final ObjectMapper objectMapper = JsonMapper.builder() @@ -31,17 +37,56 @@ public class GraphService { .serializationInclusion(JsonInclude.Include.NON_EMPTY) .build(); - public GraphService(AstraQueryServiceBase searcher, GraphConfig graphConfig) { - this.searcher = searcher; - this.graphConfig = graphConfig; + public GraphService(TraceFetcher traceFetcher, GraphConfig graphConfig) { + this.traceFetcher = traceFetcher; + this.graphBuilder = new GraphBuilder(graphConfig); - LOG.info("Started GraphService with config: {}", this.graphConfig); + LOG.info("Started GraphService with GraphBuilder config: {}", graphConfig); } + private record SubgraphResponse( + Graph subgraph, long traceFetchTimeMs, long subgraphBuildTimeMs) {} + @Get @Path("/api/v1/trace/{traceId}/subgraph") - public HttpResponse getSubgraph(@Param("traceId") String traceId) throws IOException { - String output = "[]"; + public HttpResponse getSubgraph( + @Param("traceId") String traceId, + @Param("buildFilter") Optional buildFilterJson, + @Param("maxSpans") Optional maxSpans, + @Header("X-User-Request") Optional userRequest) + throws IOException { + // Parse the filter from JSON string if provided + Optional buildFilter = Optional.empty(); + if (buildFilterJson.isPresent()) { + try { + // Parse JSON as Map> and create GraphConfig.Filter + TypeReference>> typeRef = new TypeReference<>() {}; + Map> filterMap = + objectMapper.readValue(buildFilterJson.get(), typeRef); + buildFilter = Optional.of(new GraphBuilder.Filter(filterMap)); + } catch (Exception e) { + LOG.error("Failed to parse buildFilter JSON: {}", buildFilterJson.get(), e); + return HttpResponse.of( + HttpStatus.BAD_REQUEST, + MediaType.PLAIN_TEXT_UTF_8, + "Invalid buildFilter JSON: " + e.getMessage()); + } + } + + long start = System.currentTimeMillis(); + List trace = + this.traceFetcher.getSpansByTraceId( + traceId, Optional.empty(), Optional.empty(), maxSpans, userRequest, Optional.empty()); + long end = System.currentTimeMillis(); + long traceFetchTime = end - start; + + start = System.currentTimeMillis(); + Graph subgraph = this.graphBuilder.buildFromSpans(trace, buildFilter); + end = System.currentTimeMillis(); + long subgraphBuildTime = end - start; + + SubgraphResponse response = new SubgraphResponse(subgraph, traceFetchTime, subgraphBuildTime); + String output = objectMapper.writeValueAsString(response); return HttpResponse.of(HttpStatus.OK, MediaType.JSON_UTF_8, output); } } diff --git a/astra/src/main/java/com/slack/astra/server/Astra.java b/astra/src/main/java/com/slack/astra/server/Astra.java index 1274498084..958b4142eb 100644 --- a/astra/src/main/java/com/slack/astra/server/Astra.java +++ b/astra/src/main/java/com/slack/astra/server/Astra.java @@ -266,7 +266,7 @@ private static Set getServices( .withTracing(astraConfig.getTracingConfig()) .withAnnotatedService(new ElasticsearchApiService(astraDistributedQueryService)) .withAnnotatedService(new ZipkinService(tf)) - .withAnnotatedService(new GraphService(astraDistributedQueryService, graphConfig)) + .withAnnotatedService(new GraphService(tf, graphConfig)) .withGrpcService(astraDistributedQueryService) .build(); services.add(armeriaService); diff --git a/astra/src/test/java/com/slack/astra/graphApi/GraphBuilderTest.java b/astra/src/test/java/com/slack/astra/graphApi/GraphBuilderTest.java index a097f50fe0..4fb78c36f4 100644 --- a/astra/src/test/java/com/slack/astra/graphApi/GraphBuilderTest.java +++ b/astra/src/test/java/com/slack/astra/graphApi/GraphBuilderTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.slack.astra.zipkinApi.ZipkinEndpointResponse; import com.slack.astra.zipkinApi.ZipkinSpanResponse; import java.io.File; import java.io.IOException; @@ -11,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.SortedMap; import java.util.TreeMap; import org.junit.jupiter.api.BeforeEach; @@ -40,50 +40,12 @@ void setUp() throws IOException { @Test void buildFromSpans_emptyList_returnsEmptyGraph() { List spans = new ArrayList<>(); - Graph graph = defaultGraphBuilder.buildFromSpans(spans); + Graph graph = defaultGraphBuilder.buildFromSpans(spans, Optional.empty()); assertThat(graph.nodes()).isEmpty(); assertThat(graph.edges()).isEmpty(); } - @Test - void buildFromSpans_singleSpanWithoutParent_createsSingleNodeWithoutEdges() { - List spans = - List.of( - TestUtils.createSpanWithTags( - "span1", - "trace1", - null, - Map.of( - "kube.app", - "app1", - "kube.namespace", - "ns1", - "operation_name", - "op1", - "resource", - "res1"))); - - Graph graph = configuredGraphBuilder.buildFromSpans(spans); - - assertThat(graph.nodes()).hasSize(1); - Node node = graph.nodes().getFirst(); - - // Verify the node ID matches the expected hash - SortedMap expectedNodeMetadata = - new TreeMap<>( - Map.of( - "app", "app1", - "namespace", "ns1", - "resource", "res1")); - String expectedId = Node.generateIdFromMetadata(expectedNodeMetadata); - - assertThat(node.getId()).isEqualTo(expectedId); - assertThat(node.getMetadata()).isEqualTo(expectedNodeMetadata); - - assertThat(graph.edges()).isEmpty(); - } - @Test void buildFromSpans_parentChildSpans_createsNodesWithEdge() { List spans = @@ -102,12 +64,20 @@ void buildFromSpans_parentChildSpans_createsNodesWithEdge() { "trace1", "parent1", Map.of( - "kube.app", "app2", - "kube.namespace", "ns2", - "operation_name", "op2", - "resource", "res2"))); + "kube.app", + "app2", + "kube.namespace", + "ns2", + "operation_name", + "http.request", + "resource", + "res2", + "tag.http.target.canonical_path", + "/v2/res2", + "tag.http.target.host", + "app2.ns2"))); - Graph graph = configuredGraphBuilder.buildFromSpans(spans); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.empty()); assertThat(graph.nodes()).hasSize(2); assertThat(graph.edges()).hasSize(1); @@ -116,19 +86,18 @@ void buildFromSpans_parentChildSpans_createsNodesWithEdge() { SortedMap parentMetadata = new TreeMap<>( Map.of( - "app", "app1", - "namespace", "ns1", + "service", "app1.ns1", "resource", "res1")); String expectedParentId = Node.generateIdFromMetadata(parentMetadata); SortedMap childMetadata = new TreeMap<>( Map.of( - "app", "app2", - "namespace", "ns2", - "resource", "res2")); + "service", "app2.ns2", + "resource", "/v2/res2")); - SortedMap edgeMetadata = new TreeMap<>(Map.of("operation", "op2")); + // uses canonical path as resource + SortedMap edgeMetadata = new TreeMap<>(Map.of("operation", "http.request")); String expectedChildId = Node.generateIdFromMetadata(childMetadata); Edge edge = graph.edges().iterator().next(); assertThat(edge.sourceNodeId()).isEqualTo(expectedParentId); @@ -136,71 +105,6 @@ void buildFromSpans_parentChildSpans_createsNodesWithEdge() { assertThat(edge.metadata()).isEqualTo(edgeMetadata); } - @Test - void buildFromSpans_httpRequestSpan_usesCanonicalPathAsResource() { - List spans = - List.of( - TestUtils.createSpanWithTags( - "span1", - "trace1", - null, - Map.of( - "kube.app", "app1", - "kube.namespace", "ns1", - "operation_name", "http.request", - "resource", "original_resource", - "tag.operation.canonical_path", "/api/users"))); - - Graph graph = configuredGraphBuilder.buildFromSpans(spans); - - assertThat(graph.nodes()).hasSize(1); - Node node = graph.nodes().getFirst(); - assertThat(node.getMetadata().get("resource")).isEqualTo("/api/users"); - } - - @Test - void buildFromSpans_httpRequestSpanWithoutCanonicalPath_usesOriginalResource() { - List spans = - List.of( - TestUtils.createSpanWithTags( - "span1", - "trace1", - null, - Map.of( - "kube.app", "app1", - "kube.namespace", "ns1", - "operation_name", "http.request", - "resource", "original_resource"))); - - Graph graph = configuredGraphBuilder.buildFromSpans(spans); - - assertThat(graph.nodes()).hasSize(1); - Node node = graph.nodes().getFirst(); - assertThat(node.getMetadata().get("resource")).isEqualTo("original_resource"); - } - - @Test - void buildFromSpans_missingTags_usesDefaultValues() { - List spans = - List.of(TestUtils.createSpanWithTags("span1", "trace1", null, Map.of())); - - Graph graph = configuredGraphBuilder.buildFromSpans(spans); - - assertThat(graph.nodes()).hasSize(1); - Node node = graph.nodes().getFirst(); - - SortedMap expectedMetadata = - new TreeMap<>( - Map.of( - "app", "unknown_app", - "namespace", "unknown_namespace", - "resource", "unknown_resource")); - String expectedId = Node.generateIdFromMetadata(expectedMetadata); - - assertThat(node.getMetadata()).isEqualTo(expectedMetadata); - assertThat(node.getId()).isEqualTo(expectedId); - } - @Test void buildFromSpans_spanWithNullId_skipsSpan() { List spans = @@ -226,62 +130,11 @@ void buildFromSpans_spanWithNullId_skipsSpan() { "operation_name", "op2", "resource", "res2"))); - Graph graph = configuredGraphBuilder.buildFromSpans(spans); - - assertThat(graph.nodes()).hasSize(1); - Node node = graph.nodes().getFirst(); - assertThat(node.getMetadata().get("app")).isEqualTo("app1"); - } - - @Test - void buildFromSpans_childSpanWithNonExistentParent_createsChildNodeWithoutEdge() { - List spans = - List.of( - TestUtils.createSpanWithTags( - "child1", - "trace1", - "nonexistent_parent", - Map.of( - "kube.app", "app1", - "kube.namespace", "ns1", - "operation_name", "op1", - "resource", "res1"))); - - Graph graph = configuredGraphBuilder.buildFromSpans(spans); - - assertThat(graph.nodes()).hasSize(1); - assertThat(graph.edges()).isEmpty(); - } - - @Test - void buildFromSpans_duplicateNodes_deduplicatesNodes() { - List spans = - List.of( - // two spans that would create the same node - TestUtils.createSpanWithTags( - "span1", - "trace1", - null, - Map.of( - "kube.app", "app1", - "kube.namespace", "ns1", - "operation_name", "op1", - "resource", "res1")), - TestUtils.createSpanWithTags( - "span2", - "trace1", - null, - Map.of( - "kube.app", "app1", - "kube.namespace", "ns1", - "operation_name", "op1", - "resource", "res1"))); - - Graph graph = configuredGraphBuilder.buildFromSpans(spans); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.empty()); - // should only have one unique node - assertThat(graph.nodes()).hasSize(1); - assertThat(graph.edges()).isEmpty(); + assertThat(graph.edges()).hasSize(0); + // No nodes because there are no edges + assertThat(graph.nodes()).hasSize(0); } @Test @@ -316,7 +169,7 @@ void buildFromSpans_multipleChildrenSameParent_createsMultipleEdges() { "operation_name", "op3", "resource", "res3"))); - Graph graph = configuredGraphBuilder.buildFromSpans(spans); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.empty()); assertThat(graph.nodes()).hasSize(3); assertThat(graph.edges()).hasSize(2); @@ -324,24 +177,21 @@ void buildFromSpans_multipleChildrenSameParent_createsMultipleEdges() { SortedMap parentMetadata = new TreeMap<>( Map.of( - "app", "app1", - "namespace", "ns1", + "service", "app1.ns1", "resource", "res1")); String expectedParentId = Node.generateIdFromMetadata(parentMetadata); SortedMap child1Metadata = new TreeMap<>( Map.of( - "app", "app2", - "namespace", "ns2", + "service", "app2.ns2", "resource", "res2")); String expectedChild1Id = Node.generateIdFromMetadata(child1Metadata); SortedMap child2Metadata = new TreeMap<>( Map.of( - "app", "app3", - "namespace", "ns3", + "service", "app3.ns3", "resource", "res3")); String expectedChild2Id = Node.generateIdFromMetadata(child2Metadata); @@ -394,7 +244,7 @@ void buildFromSpans_duplicateEdges_deduplicatesEdges() { "operation_name", "op2", "resource", "res2"))); - Graph graph = configuredGraphBuilder.buildFromSpans(spans); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.empty()); // should have 2 nodes (parent and child - child nodes are deduplicated) assertThat(graph.nodes()).hasSize(2); @@ -407,16 +257,14 @@ void buildFromSpans_duplicateEdges_deduplicatesEdges() { SortedMap parentMetadata = new TreeMap<>( Map.of( - "app", "app1", - "namespace", "ns1", + "service", "app1.ns1", "resource", "res1")); String expectedParentId = Node.generateIdFromMetadata(parentMetadata); SortedMap childMetadata = new TreeMap<>( Map.of( - "app", "app2", - "namespace", "ns2", + "service", "app2.ns2", "resource", "res2")); String expectedChildId = Node.generateIdFromMetadata(childMetadata); @@ -473,7 +321,7 @@ void buildFromSpans_complexHierarchy_buildsCorrectGraph() { "resource", "gc_res"))); - Graph graph = configuredGraphBuilder.buildFromSpans(spans); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.empty()); assertThat(graph.nodes()).hasSize(4); assertThat(graph.edges()).hasSize(3); @@ -481,32 +329,28 @@ void buildFromSpans_complexHierarchy_buildsCorrectGraph() { SortedMap rootMetadata = new TreeMap<>( Map.of( - "app", "root_app", - "namespace", "root_ns", + "service", "root_app.root_ns", "resource", "root_res")); String expectedRootId = Node.generateIdFromMetadata(rootMetadata); SortedMap child1Metadata = new TreeMap<>( Map.of( - "app", "child1_app", - "namespace", "child1_ns", + "service", "child1_app.child1_ns", "resource", "child1_res")); String expectedChild1Id = Node.generateIdFromMetadata(child1Metadata); SortedMap child2Metadata = new TreeMap<>( Map.of( - "app", "child2_app", - "namespace", "child2_ns", + "service", "child2_app.child2_ns", "resource", "child2_res")); String expectedChild2Id = Node.generateIdFromMetadata(child2Metadata); SortedMap grandchildMetadata = new TreeMap<>( Map.of( - "app", "gc_app", - "namespace", "gc_ns", + "service", "gc_app.gc_ns", "resource", "gc_res")); String expectedGrandchildId = Node.generateIdFromMetadata(grandchildMetadata); @@ -535,21 +379,1079 @@ void buildFromSpans_complexHierarchy_buildsCorrectGraph() { } @Test - void buildFromSpans_defaultConfig_usesRemoteEndpointServiceName() { - List spans = new ArrayList<>(); - ZipkinSpanResponse span = new ZipkinSpanResponse("span1", "trace1"); + void buildFromSpans_noFilterWithCycles_buildCorrectGraph() { + // Topology: A -> spanB1 -> C + // D -> spanB2 -> E + // E -> spanB3 (creates cycle back to node B) + // where spanB1, spanB2, spanB3 all represent the same logical node + List spans = + List.of( + // Root span A + TestUtils.createSpanWithTags( + "spanA", + "trace1", + null, + Map.of( + "kube.app", "appA", + "kube.namespace", "nsA", + "operation_name", "opA", + "resource", "resA")), + // Span B1 - child of A + TestUtils.createSpanWithTags( + "spanB1", + "trace1", + "spanA", + Map.of( + "kube.app", "appB", + "kube.namespace", "nsB", + "operation_name", "opB", + "resource", "resB")), + // Span C - child of B1 + TestUtils.createSpanWithTags( + "spanC", + "trace1", + "spanB1", + Map.of( + "kube.app", "appC", + "kube.namespace", "nsC", + "operation_name", "opC", + "resource", "resC")), + // Span D - another root + TestUtils.createSpanWithTags( + "spanD", + "trace1", + null, + Map.of( + "kube.app", "appD", + "kube.namespace", "nsD", + "operation_name", "opD", + "resource", "resD")), + // Span B2 - child of D + TestUtils.createSpanWithTags( + "spanB2", + "trace1", + "spanD", + Map.of( + "kube.app", "appB", + "kube.namespace", "nsB", + "operation_name", "opB", + "resource", "resB")), + // Span E - child of B2 + TestUtils.createSpanWithTags( + "spanE", + "trace1", + "spanB2", + Map.of( + "kube.app", "appE", + "kube.namespace", "nsE", + "operation_name", "opE", + "resource", "resE")), + // Span B3 - child of E, creates cycle back to node B + TestUtils.createSpanWithTags( + "spanB3", + "trace1", + "spanE", + Map.of( + "kube.app", "appB", + "kube.namespace", "nsB", + "operation_name", "opB", + "resource", "resB"))); + + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.empty()); + + // Should have 5 nodes: A, B, C, D, E + assertThat(graph.nodes()).hasSize(5); + + // Should have edges: A->B, B->C, D->B, B->E, E->B (cycle) + assertThat(graph.edges()).hasSize(5); + + SortedMap nodeAMetadata = + new TreeMap<>( + Map.of( + "service", "appA.nsA", + "resource", "resA")); + String expectedNodeAId = Node.generateIdFromMetadata(nodeAMetadata); + + SortedMap nodeBMetadata = + new TreeMap<>( + Map.of( + "service", "appB.nsB", + "resource", "resB")); + String expectedNodeBId = Node.generateIdFromMetadata(nodeBMetadata); + + SortedMap nodeCMetadata = + new TreeMap<>( + Map.of( + "service", "appC.nsC", + "resource", "resC")); + String expectedNodeCId = Node.generateIdFromMetadata(nodeCMetadata); + + SortedMap nodeDMetadata = + new TreeMap<>( + Map.of( + "service", "appD.nsD", + "resource", "resD")); + String expectedNodeDId = Node.generateIdFromMetadata(nodeDMetadata); + + SortedMap nodeEMetadata = + new TreeMap<>( + Map.of( + "service", "appE.nsE", + "resource", "resE")); + String expectedNodeEId = Node.generateIdFromMetadata(nodeEMetadata); + + // A -> B + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeAId) + && edge.targetNodeId().equals(expectedNodeBId) + && edge.metadata().equals(new TreeMap<>(Map.of("operation", "opB")))); + + // B -> C + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeBId) + && edge.targetNodeId().equals(expectedNodeCId) + && edge.metadata().equals(new TreeMap<>(Map.of("operation", "opC")))); + + // D -> B + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeDId) + && edge.targetNodeId().equals(expectedNodeBId) + && edge.metadata().equals(new TreeMap<>(Map.of("operation", "opB")))); + + // B -> E + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeBId) + && edge.targetNodeId().equals(expectedNodeEId) + && edge.metadata().equals(new TreeMap<>(Map.of("operation", "opE")))); + + // E -> B + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeEId) + && edge.targetNodeId().equals(expectedNodeBId) + && edge.metadata().equals(new TreeMap<>(Map.of("operation", "opB")))); + } + + @Test + void buildFromSpans_withFilter_includesOnlyMatchingNodesWithEdges() { + List spans = + List.of( + TestUtils.createSpanWithTags( + "parent1", + "trace1", + null, + Map.of( + "kube.app", + "app1", + "kube.namespace", + "ns1", + "operation_name", + "http.request", + "resource", + "res1", + "tag.http.target.canonical_path", + "/v2/target1", + "tag.http.target.host", + "target_app1.target_ns1")), + TestUtils.createSpanWithTags( + "child1", + "trace1", + "parent1", + Map.of( + "kube.app", + "app2", + "kube.namespace", + "ns2", + "operation_name", + "http.request", + "resource", + "res2", + "tag.http.target.canonical_path", + "/v2/target2", + "tag.http.target.host", + "target_app2.target_ns2")), + TestUtils.createSpanWithTags( + "child2", + "trace1", + "parent1", + Map.of( + "kube.app", "app3", + "kube.namespace", "ns3", + "operation_name", "op3", + "resource", "res3"))); + + // Filter to only include nodes with operation "http.request" + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("http.request"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + // Should only include parent1 and child1 nodes (both match filter and have edge between them) + assertThat(graph.nodes()).hasSize(2); + assertThat(graph.edges()).hasSize(1); - ZipkinEndpointResponse remoteEndpoint = new ZipkinEndpointResponse(); - remoteEndpoint.setServiceName("test-service"); - span.setRemoteEndpoint(remoteEndpoint); - span.setTags(Map.of()); + SortedMap parentMetadata = + new TreeMap<>( + Map.of( + "service", "target_app1.target_ns1", + "resource", "/v2/target1")); + String expectedParentId = Node.generateIdFromMetadata(parentMetadata); + + SortedMap child1Metadata = + new TreeMap<>( + Map.of( + "service", "target_app2.target_ns2", + "resource", "/v2/target2")); + String expectedChild1Id = Node.generateIdFromMetadata(child1Metadata); + + Edge edge = graph.edges().get(0); + assertThat(edge.sourceNodeId()).isEqualTo(expectedParentId); + assertThat(edge.targetNodeId()).isEqualTo(expectedChild1Id); + assertThat(edge.metadata()).isEqualTo(new TreeMap<>(Map.of("operation", "http.request"))); + } - spans.add(span); + @Test + void buildFromSpans_withFilter_skipsIntermediateNonMatchingNodes() { + List spans = + List.of( + // root - operation: http.request + TestUtils.createSpanWithTags( + "root", + "trace1", + null, + Map.of( + "kube.app", + "root_app", + "kube.namespace", + "root_ns", + "operation_name", + "http.request", + "resource", + "root_res", + "tag.http.target.canonical_path", + "/v2/target1", + "tag.http.target.host", + "target_app1.target_ns1")), + // intermediate child - operation: op2 (doesn't match filter) + TestUtils.createSpanWithTags( + "child1", + "trace1", + "root", + Map.of( + "kube.app", "child1_app", + "kube.namespace", "child1_ns", + "operation_name", "op2", + "resource", "child1_res")), + // grandchild - operation: http.request (matches filter) + TestUtils.createSpanWithTags( + "grandchild", + "trace1", + "child1", + Map.of( + "kube.app", + "gc_app", + "kube.namespace", + "gc_ns", + "operation_name", + "http.request", + "resource", + "gc_res", + "tag.http.target.canonical_path", + "/v2/target2", + "tag.http.target.host", + "target_app2.target_ns2"))); + + // Filter to only include nodes with operation "http.request" + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("http.request"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + // Should include root and grandchild nodes, skipping intermediate child1 + assertThat(graph.nodes()).hasSize(2); + assertThat(graph.edges()).hasSize(1); - Graph graph = defaultGraphBuilder.buildFromSpans(spans); + SortedMap rootMetadata = + new TreeMap<>( + Map.of( + "service", "target_app1.target_ns1", + "resource", "/v2/target1")); + String expectedRootId = Node.generateIdFromMetadata(rootMetadata); + + SortedMap grandchildMetadata = + new TreeMap<>( + Map.of( + "service", "target_app2.target_ns2", + "resource", "/v2/target2")); + String expectedGrandchildId = Node.generateIdFromMetadata(grandchildMetadata); + + // Should have edge directly from root to grandchild (skipping intermediate) + Edge edge = graph.edges().get(0); + assertThat(edge.sourceNodeId()).isEqualTo(expectedRootId); + assertThat(edge.targetNodeId()).isEqualTo(expectedGrandchildId); + assertThat(edge.metadata()).isEqualTo(new TreeMap<>(Map.of("operation", "http.request"))); + } + + @Test + void buildFromSpans_withFilter_noMatchingNodes_returnsEmptyGraph() { + List spans = + List.of( + TestUtils.createSpanWithTags( + "parent1", + "trace1", + null, + Map.of( + "kube.app", "app1", + "kube.namespace", "ns1", + "operation_name", "op1", + "resource", "res1")), + TestUtils.createSpanWithTags( + "child1", + "trace1", + "parent1", + Map.of( + "kube.app", "app2", + "kube.namespace", "ns2", + "operation_name", "op2", + "resource", "res2"))); + + // Filter that doesn't match any nodes + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("nonexistent.operation"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + assertThat(graph.nodes()).isEmpty(); + assertThat(graph.edges()).isEmpty(); + } + + @Test + void buildFromSpans_withFilter_multipleMatchingPaths() { + List spans = + List.of( + // root - http.request + TestUtils.createSpanWithTags( + "root", + "trace1", + null, + Map.of( + "kube.app", + "root_app", + "kube.namespace", + "root_ns", + "operation_name", + "http.request", + "resource", + "root_res", + "tag.http.target.canonical_path", + "/v2/target1", + "tag.http.target.host", + "target_app1.target_ns1")), + // child1 - http.request + TestUtils.createSpanWithTags( + "child1", + "trace1", + "root", + Map.of( + "kube.app", + "child1_app", + "kube.namespace", + "child1_ns", + "operation_name", + "http.request", + "resource", + "child1_res", + "tag.http.target.canonical_path", + "/v2/target2", + "tag.http.target.host", + "target_app2.target_app2")), + // child2 - http.request + TestUtils.createSpanWithTags( + "child2", + "trace1", + "root", + Map.of( + "kube.app", + "child2_app", + "kube.namespace", + "child2_ns", + "operation_name", + "http.request", + "resource", + "child2_res", + "tag.http.target.canonical_path", + "/v2/target3", + "tag.http.target.host", + "target_app3.target+_ns3"))); + + // Filter to only include nodes with operation "http.request" + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("http.request"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + // Should include all 3 nodes since they all match + assertThat(graph.nodes()).hasSize(3); + // Should have 2 edges: root->child1 and root->child2 + assertThat(graph.edges()).hasSize(2); + + SortedMap rootMetadata = + new TreeMap<>( + Map.of( + "service", "target_app1.target_ns1", + "resource", "/v2/target1")); + String expectedRootId = Node.generateIdFromMetadata(rootMetadata); + + // Both edges should originate from root + assertThat(graph.edges().stream().allMatch(edge -> edge.sourceNodeId().equals(expectedRootId))) + .isTrue(); + } + + @Test + void buildFromSpans_withFilter_multipleDisconnectedSubtreesWithDifferentDepths() { + List spans = + List.of( + // First subtree: intermediate1 (parent missing) -> child1 -> grandchild1 + TestUtils.createSpanWithTags( + "intermediate1", + "trace1", + "missingParent1", // parent doesn't exist in span list + Map.of( + "kube.app", "int1_app", + "kube.namespace", "int1_ns", + "operation_name", "op1", + "resource", "int1_res")), + TestUtils.createSpanWithTags( + "child1", + "trace1", + "intermediate1", + Map.of( + "kube.app", + "child1_app", + "kube.namespace", + "child1_ns", + "operation_name", + "http.request", + "resource", + "child1_res", + "tag.http.target.canonical_path", + "/v2/target1", + "tag.http.target.host", + "target_app1.target_ns1")), + TestUtils.createSpanWithTags( + "grandchild1", + "trace1", + "child1", + Map.of( + "kube.app", + "gc1_app", + "kube.namespace", + "gc1_ns", + "operation_name", + "http.request", + "resource", + "gc1_res", + "tag.http.target.canonical_path", + "/v2/target2", + "tag.http.target.host", + "target_app2.target_ns2")), + // Second subtree: non-matching nodes -> matching -> more non-matching -> matching leaf + // intermediate2a (parent missing) -> intermediate2b -> intermediate2c -> matching1 -> + // intermediate2d -> intermediate2e -> matching2 + TestUtils.createSpanWithTags( + "intermediate2a", + "trace1", + "missingParent2", // parent doesn't exist in span list + Map.of( + "kube.app", "int2a_app", + "kube.namespace", "int2a_ns", + "operation_name", "op2a", + "resource", "int2a_res")), + TestUtils.createSpanWithTags( + "intermediate2b", + "trace1", + "intermediate2a", + Map.of( + "kube.app", "int2b_app", + "kube.namespace", "int2b_ns", + "operation_name", "op2b", + "resource", "int2b_res")), + TestUtils.createSpanWithTags( + "intermediate2c", + "trace1", + "intermediate2b", + Map.of( + "kube.app", "int2c_app", + "kube.namespace", "int2c_ns", + "operation_name", "op2c", + "resource", "int2c_res")), + TestUtils.createSpanWithTags( + "matching1", + "trace1", + "intermediate2c", + Map.of( + "kube.app", + "match1_app", + "kube.namespace", + "match1_ns", + "operation_name", + "http.request", + "resource", + "match1_res", + "tag.http.target.canonical_path", + "/v2/target3", + "tag.http.target.host", + "target_app3.target_ns3")), + TestUtils.createSpanWithTags( + "intermediate2d", + "trace1", + "matching1", + Map.of( + "kube.app", "int2d_app", + "kube.namespace", "int2d_ns", + "operation_name", "op2d", + "resource", "int2d_res")), + TestUtils.createSpanWithTags( + "intermediate2e", + "trace1", + "intermediate2d", + Map.of( + "kube.app", "int2e_app", + "kube.namespace", "int2e_ns", + "operation_name", "op2e", + "resource", "int2e_res")), + TestUtils.createSpanWithTags( + "matching2", + "trace1", + "intermediate2e", + Map.of( + "kube.app", + "match2_app", + "kube.namespace", + "match2_ns", + "operation_name", + "http.request", + "resource", + "match2_res", + "tag.http.target.canonical_path", + "/v2/target4", + "tag.http.target.host", + "target_app4.target_ns4"))); + + // Filter to only include nodes with operation "http.request" + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("http.request"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + // Should include 4 matching nodes from both disconnected subtrees + assertThat(graph.nodes()).hasSize(4); + // Should have 2 edges: child1->grandchild1 and matching2->leaf2 (skipping multiple non-matching + // nodes) + assertThat(graph.edges()).hasSize(2); + + SortedMap child1Metadata = + new TreeMap<>( + Map.of( + "service", "target_app1.target_ns1", + "resource", "/v2/target1")); + String expectedChild1Id = Node.generateIdFromMetadata(child1Metadata); + + SortedMap grandchild1Metadata = + new TreeMap<>( + Map.of( + "service", "target_app2.target_ns2", + "resource", "/v2/target2")); + String expectedGrandchild1Id = Node.generateIdFromMetadata(grandchild1Metadata); + + SortedMap matching1Metadata = + new TreeMap<>( + Map.of( + "service", "target_app3.target_ns3", + "resource", "/v2/target3")); + String expectedMatching1Id = Node.generateIdFromMetadata(matching1Metadata); + + SortedMap matching2Metadata = + new TreeMap<>( + Map.of( + "service", "target_app4.target_ns4", + "resource", "/v2/target4")); + String expectedMatching2Id = Node.generateIdFromMetadata(matching2Metadata); + + // Verify both disconnected edges exist + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedChild1Id) + && edge.targetNodeId().equals(expectedGrandchild1Id)); + + // This edge should skip multiple non-matching intermediate nodes + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedMatching1Id) + && edge.targetNodeId().equals(expectedMatching2Id)); + } + + @Test + void buildFromSpans_withMultipleFilterOptions_matchesAnyOption() { + List spans = + List.of( + // Parent with operation: http.request + TestUtils.createSpanWithTags( + "parent1", + "trace1", + null, + Map.of( + "kube.app", + "app1", + "kube.namespace", + "ns1", + "operation_name", + "http.request", + "resource", + "res1", + "tag.http.target.canonical_path", + "/v2/target1", + "tag.http.target.host", + "target_app1.target_ns1")), + // Child with operation: grpc.request + TestUtils.createSpanWithTags( + "child1", + "trace1", + "parent1", + // this should use the default key since there are no rules for grpc.request + // operations + Map.of( + "kube.app", + "app2", + "kube.namespace", + "ns2", + "operation_name", + "grpc.request", + "resource", + "res2", + "tag.http.target.canonical_path", + "/v2/target2", + "tag.http.target.host", + "target_app2.target_ns2")), + // Grandchild with operation: other (doesn't match either filter option) + TestUtils.createSpanWithTags( + "grandchild1", + "trace1", + "child1", + Map.of( + "kube.app", "app3", + "kube.namespace", "ns3", + "operation_name", "other.operation", + "resource", "res3"))); + + // Filter with two options: matches nodes with either http.request OR grpc.request + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("http.request", "grpc.request"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + // Should include parent1 and child1 (both match at least one filter option) + assertThat(graph.nodes()).hasSize(2); + assertThat(graph.edges()).hasSize(1); + + SortedMap parentMetadata = + new TreeMap<>( + Map.of( + "service", "target_app1.target_ns1", + "resource", "/v2/target1")); + String expectedParentId = Node.generateIdFromMetadata(parentMetadata); + + SortedMap childMetadata = + new TreeMap<>( + Map.of( + "service", "app2.ns2", + "resource", "res2")); + String expectedChildId = Node.generateIdFromMetadata(childMetadata); + + Edge edge = graph.edges().get(0); + assertThat(edge.sourceNodeId()).isEqualTo(expectedParentId); + assertThat(edge.targetNodeId()).isEqualTo(expectedChildId); + assertThat(edge.metadata()).isEqualTo(new TreeMap<>(Map.of("operation", "grpc.request"))); + } + + @Test + void buildFromSpans_withEmptyFilter_returnsAllNodes() { + List spans = + List.of( + TestUtils.createSpanWithTags( + "parent1", + "trace1", + null, + Map.of( + "kube.app", "app1", + "kube.namespace", "ns1", + "operation_name", "op1", + "resource", "res1")), + TestUtils.createSpanWithTags( + "child1", + "trace1", + "parent1", + Map.of( + "kube.app", "app2", + "kube.namespace", "ns2", + "operation_name", "op2", + "resource", "res2")), + TestUtils.createSpanWithTags( + "child2", + "trace1", + "parent1", + Map.of( + "kube.app", "app3", + "kube.namespace", "ns3", + "operation_name", "op3", + "resource", "res3"))); + + // Empty filter - should match all nodes + GraphBuilder.Filter emptyFilter = new GraphBuilder.Filter(Map.of()); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(emptyFilter)); + + // Should include all nodes and edges (same as no filter) + assertThat(graph.nodes()).hasSize(3); + assertThat(graph.edges()).hasSize(2); + } + + @Test + void buildFromSpans_withFilterAndCycles_buildsCorrectGraph() { + // Combined test demonstrating both forward and backward transitive dependencies with cycles: + // + // Topology: + // A (http.request) -> B (op2) -> C (http.request) -> D (op2) -> E (http.request), B -> G + // Add cycles: E -> B (backward), C -> F (http.request) -> B (backward) + // + // This demonstrates: + // 1. Forward transitive deps: A -> C (through B), A -> G (through B), + // C -> E (through D), C -> F (direct) + // 2. Backward transitive deps via cycles: E -> C (backward through B), + // F -> C (backward through B) + // 3. Handles cycles without infinite loops + List spans = + new ArrayList<>( + List.of( + // Root span A - matches filter + TestUtils.createSpanWithTags( + "spanA", + "trace1", + null, + Map.of( + "kube.app", + "appA", + "kube.namespace", + "nsA", + "operation_name", + "http.request", + "resource", + "resA", + "tag.http.target.canonical_path", + "/v2/targetA", + "tag.http.target.host", + "targetA.nsA")), + // Span B - doesn't match filter, child of A + TestUtils.createSpanWithTags( + "spanB", + "trace1", + "spanA", + Map.of( + "kube.app", "appB", + "kube.namespace", "nsB", + "operation_name", "op2", + "resource", "resB")), + // Span C - matches filter, child of B + TestUtils.createSpanWithTags( + "spanC", + "trace1", + "spanB", + Map.of( + "kube.app", + "appC", + "kube.namespace", + "nsC", + "operation_name", + "http.request", + "resource", + "resC", + "tag.http.target.canonical_path", + "/v2/targetC", + "tag.http.target.host", + "targetC.nsC")), + // Span D - doesn't match filter, child of C + TestUtils.createSpanWithTags( + "spanD", + "trace1", + "spanC", + Map.of( + "kube.app", "appD", + "kube.namespace", "nsD", + "operation_name", "op2", + "resource", "resD")), + // Span E - matches filter, child of D + TestUtils.createSpanWithTags( + "spanE", + "trace1", + "spanD", + Map.of( + "kube.app", + "appE", + "kube.namespace", + "nsE", + "operation_name", + "http.request", + "resource", + "resE", + "tag.http.target.canonical_path", + "/v2/targetE", + "tag.http.target.host", + "targetE.nsE")), + // Span F - matches filter, child of C (parallel to D) + TestUtils.createSpanWithTags( + "spanF", + "trace1", + "spanC", + Map.of( + "kube.app", + "appF", + "kube.namespace", + "nsF", + "operation_name", + "http.request", + "resource", + "resF", + "tag.http.target.canonical_path", + "/v2/targetF", + "tag.http.target.host", + "targetF.nsF")), + // Create backward cycle: E -> B + TestUtils.createSpanWithTags( + "spanB_from_E", + "trace1", + "spanE", + Map.of( + "kube.app", "appB", + "kube.namespace", "nsB", + "operation_name", "op2", + "resource", "resB")), + // Create another backward cycle: F -> B (another path back) + TestUtils.createSpanWithTags( + "spanB_from_F", + "trace1", + "spanF", + Map.of( + "kube.app", "appB", + "kube.namespace", "nsB", + "operation_name", "op2", + "resource", "resB")), + // Add a child from spanB_from_E to demonstrate that children of sibling spans + // are discovered even when visitedNodes skips the sibling span itself + TestUtils.createSpanWithTags( + "spanG", + "trace1", + "spanB_from_E", + Map.of( + "kube.app", + "appG", + "kube.namespace", + "nsG", + "operation_name", + "http.request", + "resource", + "resG", + "tag.http.target.canonical_path", + "/v2/targetG", + "tag.http.target.host", + "targetG.nsG")))); + + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("http.request"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + // Should include A, C, E, F, G (all match filter) + assertThat(graph.nodes()).hasSize(5); + + // Expect 8 edges (original 5 + A->G + E->G + F->G) + // The key insight: when traversing from A through node B, ALL sibling spans of B + // (spanB, spanB_from_E, spanB_from_F) are processed together, discovering: + // - C (child of spanB) + // - G (child of spanB_from_E) + // So we get: A->C, A->G, C->E, C->F, E->C, F->C, E->G, F->G + assertThat(graph.edges()).hasSize(8); + + SortedMap nodeAMetadata = + new TreeMap<>( + Map.of( + "service", "targetA.nsA", + "resource", "/v2/targetA")); + String expectedNodeAId = Node.generateIdFromMetadata(nodeAMetadata); + + SortedMap nodeCMetadata = + new TreeMap<>( + Map.of( + "service", "targetC.nsC", + "resource", "/v2/targetC")); + String expectedNodeCId = Node.generateIdFromMetadata(nodeCMetadata); + + SortedMap nodeEMetadata = + new TreeMap<>( + Map.of( + "service", "targetE.nsE", + "resource", "/v2/targetE")); + String expectedNodeEId = Node.generateIdFromMetadata(nodeEMetadata); + + SortedMap nodeFMetadata = + new TreeMap<>( + Map.of( + "service", "targetF.nsF", + "resource", "/v2/targetF")); + String expectedNodeFId = Node.generateIdFromMetadata(nodeFMetadata); + + SortedMap nodeGMetadata = + new TreeMap<>( + Map.of( + "service", "targetG.nsG", + "resource", "/v2/targetG")); + String expectedNodeGId = Node.generateIdFromMetadata(nodeGMetadata); + + // A -> C + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeAId) + && edge.targetNodeId().equals(expectedNodeCId)); + + // A -> G + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeAId) + && edge.targetNodeId().equals(expectedNodeGId)); + + // C -> E + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeCId) + && edge.targetNodeId().equals(expectedNodeEId)); + + // C -> F + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeCId) + && edge.targetNodeId().equals(expectedNodeFId)); + + // E -> C + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeEId) + && edge.targetNodeId().equals(expectedNodeCId)); + + // F -> C + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeFId) + && edge.targetNodeId().equals(expectedNodeCId)); + + // E -> G + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeEId) + && edge.targetNodeId().equals(expectedNodeGId)); + + // F -> G + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeFId) + && edge.targetNodeId().equals(expectedNodeGId)); + } + + @Test + void buildFromSpans_withFilterSelfLoopSameLogicalNode_buildsCorrectGraph() { + List spans = + new ArrayList<>( + List.of( + TestUtils.createSpanWithTags( + "spanA", + "trace1", + null, + Map.of( + "kube.app", + "app1", + "kube.namespace", + "ns1", + "operation_name", + "dropwizard.request", + "resource", + "res1")), + TestUtils.createSpanWithTags( + "spanB", + "trace1", + "spanA", + Map.of( + "tag.http.target.host", + "app2.ns2", + "tag.http.target.canonical_path", + "/v1/targetB", + "operation_name", + "http.request")), + // spanB and spanC point to the same logical node, but have different operations + TestUtils.createSpanWithTags( + "spanC", + "trace1", + "spanB", + Map.of( + "kube.app", + "app2", + "kube.namespace", + "ns2", + "operation_name", + "dropwizard.request", + "resource", + "/v1/targetB")))); + + GraphBuilder.Filter filter = + new GraphBuilder.Filter(Map.of("operation_name", List.of("dropwizard.request"))); + Graph graph = configuredGraphBuilder.buildFromSpans(spans, Optional.of(filter)); + + // Should include A, B (all match filter) + assertThat(graph.nodes()).hasSize(2); + + // Expect 1 edge A -> B (dropwizard.request) + assertThat(graph.edges()).hasSize(1); + + SortedMap nodeAMetadata = + new TreeMap<>( + Map.of( + "service", "app1.ns1", + "resource", "res1")); + String expectedNodeAId = Node.generateIdFromMetadata(nodeAMetadata); + + SortedMap nodeBMetadata = + new TreeMap<>( + Map.of( + "service", "app2.ns2", + "resource", "/v1/targetB")); + String expectedNodeBId = Node.generateIdFromMetadata(nodeBMetadata); + + // A -> B + assertThat(graph.edges()) + .anyMatch( + edge -> + edge.sourceNodeId().equals(expectedNodeAId) + && edge.targetNodeId().equals(expectedNodeBId)); - assertThat(graph.nodes()).hasSize(1); - Node node = graph.nodes().getFirst(); - assertThat(node.getMetadata().get("service")).isEqualTo("test-service"); + Edge edge = graph.edges().get(0); + assertThat(edge.metadata()).isEqualTo(new TreeMap<>(Map.of("operation", "dropwizard.request"))); } } diff --git a/astra/src/test/java/com/slack/astra/graphApi/GraphConfigTest.java b/astra/src/test/java/com/slack/astra/graphApi/GraphConfigTest.java index 057d12cafd..40588f6850 100644 --- a/astra/src/test/java/com/slack/astra/graphApi/GraphConfigTest.java +++ b/astra/src/test/java/com/slack/astra/graphApi/GraphConfigTest.java @@ -19,26 +19,32 @@ public void testLoadValidYamlConfig(@TempDir Path tempDir) throws IOException { """ node_metadata_tag_mapping: service: - default_key: service.name + default_key: + - service.name default_value: unknown_service rules: - field: cluster.name value: prod - override_key: prod.service.name + override_key: + - prod.service.name - field: cluster.name value: staging - override_key: test.service.name + override_key: + - test.service.name cluster: - default_key: cluster.name + default_key: + - cluster.name default_value: unknown_cluster edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation rules: - field: cluster.name value: prod - override_key: operation.prod + override_key: + - operation.prod """; Path configFile = tempDir.resolve("test-config.yaml"); @@ -51,28 +57,28 @@ public void testLoadValidYamlConfig(@TempDir Path tempDir) throws IOException { assertThat(config.getEdgeMetadataTagMapping()).hasSize(1); GraphConfig.TagConfig serviceConfig = config.getNodeMetadataTagMapping().get("service"); - assertThat(serviceConfig.getDefaultKey()).isEqualTo("service.name"); + assertThat(serviceConfig.getDefaultKey()).containsExactly("service.name"); assertThat(serviceConfig.getDefaultValue()).isEqualTo("unknown_service"); assertThat(serviceConfig.getRules()).hasSize(2); GraphConfig.RuleConfig rule1 = serviceConfig.getRules().getFirst(); - assertThat(rule1.getOverrideKey()).isEqualTo("prod.service.name"); + assertThat(rule1.getOverrideKey()).containsExactly("prod.service.name"); assertThat(rule1.getField()).isEqualTo("cluster.name"); assertThat(rule1.getValue()).isEqualTo("prod"); GraphConfig.RuleConfig rule2 = serviceConfig.getRules().get(1); - assertThat(rule2.getOverrideKey()).isEqualTo("test.service.name"); + assertThat(rule2.getOverrideKey()).containsExactly("test.service.name"); assertThat(rule2.getField()).isEqualTo("cluster.name"); assertThat(rule2.getValue()).isEqualTo("staging"); GraphConfig.TagConfig clusterConfig = config.getNodeMetadataTagMapping().get("cluster"); - assertThat(clusterConfig.getDefaultKey()).isEqualTo("cluster.name"); + assertThat(clusterConfig.getDefaultKey()).containsExactly("cluster.name"); assertThat(clusterConfig.getDefaultValue()).isEqualTo("unknown_cluster"); assertThat(clusterConfig.getRules()).hasSize(0); GraphConfig.TagConfig connectionTypeConfig = config.getEdgeMetadataTagMapping().get("operation"); - assertThat(connectionTypeConfig.getDefaultKey()).isEqualTo("operation_name"); + assertThat(connectionTypeConfig.getDefaultKey()).containsExactly("operation_name"); assertThat(connectionTypeConfig.getDefaultValue()).isEqualTo("unknown_operation"); assertThat(connectionTypeConfig.getRules()).hasSize(1); } @@ -111,14 +117,17 @@ public void testResolveWithDefaultValue() throws IOException { """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation """); Map tags = new HashMap<>(); @@ -139,14 +148,17 @@ public void testResolveWithUnknownField() throws IOException { """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation """); Map tags = Map.of("some.tag", "some-value"); @@ -167,26 +179,32 @@ public void testResolveWithMatchingRule() throws IOException { """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app rules: - field: namespace.name value: prod-ns - override_key: prod.app.name + override_key: + - prod.app.name - field: cluster.name value: east - override_key: east.app.name + override_key: + - east.app.name namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation rules: - field: namespace.name value: prod-ns - override_key: operation.prod + override_key: + - operation.prod """); Map tags = Map.of( @@ -215,38 +233,44 @@ public void testResolveWithMatchingRuleButMissingOverrideKey() throws IOExceptio """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app rules: - field: namespace.name value: prod-ns - override_key: prod.app.name + override_key: + - prod.app.name - field: cluster.name value: east - override_key: east.app.name + override_key: + - east.app.name namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation rules: - field: namespace.name value: prod-ns - override_key: operation.prod + override_key: + - operation.prod """); Map tags = Map.of( "app.name", "my-app", "namespace.name", "prod-ns", "operation_name", "some_operation"); - // Node + // Node - rule matches but override key is missing, should return default value String result = config.resolve(tags, "app", GraphConfig.EntityType.NODE); - assertThat(result).isEqualTo("my-app"); + assertThat(result).isEqualTo("unknown_app"); - // Edge + // Edge - rule matches but override key is missing, should return default value result = config.resolve(tags, "operation", GraphConfig.EntityType.EDGE); - assertThat(result).isEqualTo("some_operation"); + assertThat(result).isEqualTo("unknown_operation"); } @Test @@ -256,26 +280,32 @@ public void testResolveWithNonMatchingRule() throws IOException { """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app rules: - field: namespace.name value: prod-ns - override_key: prod.app.name + override_key: + - prod.app.name - field: cluster.name value: east - override_key: east.app.name + override_key: + - east.app.name namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation rules: - field: namespace.name value: prod-ns - override_key: operation.prod + override_key: + - operation.prod """); Map tags = Map.of( @@ -297,29 +327,36 @@ public void testResolveWithMultipleRules() throws IOException { """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app rules: - field: namespace.name value: prod-ns - override_key: prod.app.name + override_key: + - prod.app.name - field: cluster.name value: east - override_key: east.app.name + override_key: + - east.app.name namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation rules: - field: namespace.name value: prod-ns - override_key: operation.prod + override_key: + - operation.prod - field: cluster.name value: east - override_key: operation.east + override_key: + - operation.east """); Map tags = Map.of( @@ -356,29 +393,36 @@ public void testResolveWithMultipleRulesNoMatch() throws IOException { """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app rules: - field: namespace.name value: prod-ns - override_key: prod.app.name + override_key: + - prod.app.name - field: cluster.name value: east - override_key: east.app.name + override_key: + - east.app.name namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation rules: - field: namespace.name value: prod-ns - override_key: operation.prod + override_key: + - operation.prod - field: cluster.name value: east - override_key: operation_east + override_key: + - operation_east """); Map tags = Map.of( @@ -402,6 +446,133 @@ public void testResolveWithMultipleRulesNoMatch() throws IOException { assertThat(result).isEqualTo("some_operation"); } + @Test + public void testResolveWithMultipleDefaultKeys() throws IOException { + GraphConfig config = + GraphConfig.load( + """ + node_metadata_tag_mapping: + service: + default_key: + - kube.app + - kube.namespace + default_value: unknown_service + key_delimiter: . + """); + Map tags = Map.of("kube.app", "my-app", "kube.namespace", "prod"); + + String result = config.resolve(tags, "service", GraphConfig.EntityType.NODE); + assertThat(result).isEqualTo("my-app.prod"); + } + + @Test + public void testResolveWithMultipleDefaultKeys_missingKey() throws IOException { + GraphConfig config = + GraphConfig.load( + """ + node_metadata_tag_mapping: + service: + default_key: + - kube.app + - kube.namespace + default_value: unknown_service + key_delimiter: . + """); + Map tags = Map.of("kube.app", "my-app"); + + String result = config.resolve(tags, "service", GraphConfig.EntityType.NODE); + // Should return default value when any key is missing + assertThat(result).isEqualTo("unknown_service"); + } + + @Test + public void testResolveWithSingleDefaultKey() throws IOException { + GraphConfig config = + GraphConfig.load( + """ + node_metadata_tag_mapping: + service: + default_key: + - kube.app + default_value: unknown_service + """); + Map tags = Map.of("kube.app", "my-app"); + + String result = config.resolve(tags, "service", GraphConfig.EntityType.NODE); + assertThat(result).isEqualTo("my-app"); + } + + @Test + public void testResolveWithMultipleOverrideKeys() throws IOException { + GraphConfig config = + GraphConfig.load( + """ + node_metadata_tag_mapping: + service: + default_key: + - kube.app + - kube.namespace + default_value: unknown_service + key_delimiter: . + rules: + - field: operation_name + value: http.request + override_key: + - tag.http.target.host + - tag.http.method + """); + Map tags = + Map.of( + "kube.app", + "my-app", + "kube.namespace", + "prod", + "operation_name", + "http.request", + "tag.http.target.host", + "api.example.com", + "tag.http.method", + "GET"); + + String result = config.resolve(tags, "service", GraphConfig.EntityType.NODE); + assertThat(result).isEqualTo("api.example.com.GET"); + } + + @Test + public void testResolveWithMultipleOverrideKeys_missingKey() throws IOException { + GraphConfig config = + GraphConfig.load( + """ + node_metadata_tag_mapping: + service: + default_key: + - kube.app + - kube.namespace + default_value: unknown_service + key_delimiter: . + rules: + - field: operation_name + value: http.request + override_key: + - tag.http.target.host + - tag.http.method + """); + Map tags = + Map.of( + "kube.app", + "my-app", + "kube.namespace", + "prod", + "operation_name", + "http.request", + "tag.http.target.host", + "api.example.com"); + + String result = config.resolve(tags, "service", GraphConfig.EntityType.NODE); + // Should return default value since override key is missing tag.http.method + assertThat(result).isEqualTo("unknown_service"); + } + @Test public void testCreateNodeMetadataFromSpan_defaultConfig_usesRemoteEndpointServiceName() { GraphConfig config = GraphConfig.DEFAULT; @@ -421,13 +592,16 @@ public void testCreatNodeMetadataFromSpan_customConfig_usesTagMapping() throws I """ node_metadata_tag_mapping: app: - default_key: app.name + default_key: + - app.name default_value: unknown_app namespace: - default_key: namespace.name + default_key: + - namespace.name default_value: unknown_namespace resource: - default_key: resource.name + default_key: + - resource.name default_value: unknown_resource """); @@ -465,7 +639,8 @@ public void testCreatEdgeMetadataFromSpan_customConfig_usesTagMapping() throws I """ edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation """); diff --git a/astra/src/test/java/com/slack/astra/graphApi/GraphServiceTest.java b/astra/src/test/java/com/slack/astra/graphApi/GraphServiceTest.java index f461948575..829392789c 100644 --- a/astra/src/test/java/com/slack/astra/graphApi/GraphServiceTest.java +++ b/astra/src/test/java/com/slack/astra/graphApi/GraphServiceTest.java @@ -1,34 +1,441 @@ package com.slack.astra.graphApi; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; -import com.slack.astra.server.AstraQueryServiceBase; +import com.slack.astra.zipkinApi.TraceFetcher; +import java.io.File; import java.io.IOException; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.mockito.MockitoAnnotations; public class GraphServiceTest { - @Mock private AstraQueryServiceBase searcher; + @Mock private TraceFetcher traceFetcher; private GraphService graphService; + private ObjectMapper objectMapper; @BeforeEach public void setup() throws IOException { - graphService = spy(new GraphService(searcher, GraphConfig.DEFAULT)); + MockitoAnnotations.openMocks(this); + + // Load custom config from YAML file + Path configPath = + new File( + Objects.requireNonNull( + getClass() + .getClassLoader() + .getResource("test-dependency-graph-config.yaml")) + .getFile()) + .toPath(); + graphService = spy(new GraphService(traceFetcher, GraphConfig.load(configPath))); + objectMapper = new ObjectMapper(); } @Test public void testGetSubgraphByTraceId_emptyResult() throws Exception { - String traceId = "test_trace_1"; + String traceId = "test_trace_empty"; + + when(traceFetcher.getSpansByTraceId( + anyString(), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class))) + .thenReturn(Collections.emptyList()); - HttpResponse response = graphService.getSubgraph(traceId); + HttpResponse response = + graphService.getSubgraph(traceId, Optional.empty(), Optional.empty(), Optional.empty()); AggregatedHttpResponse aggregatedResponse = response.aggregate().join(); assertEquals(HttpStatus.OK, aggregatedResponse.status()); - assertEquals("[]", aggregatedResponse.contentUtf8()); + + // Verify it's valid JSON with nodes and edges arrays + String content = aggregatedResponse.contentUtf8(); + JsonNode jsonNode = objectMapper.readTree(content); + + assertTrue(jsonNode.has("subgraph")); + assertTrue(jsonNode.has("traceFetchTimeMs")); + assertTrue(jsonNode.has("subgraphBuildTimeMs")); + + assertTrue(jsonNode.get("subgraph").isEmpty()); + } + + @Test + public void testGetSubgraphByTraceId_withComplexHierarchy() throws Exception { + String traceId = "test_trace_complex"; + + // Create a complex span hierarchy: + // root (API Gateway) + // ├── child1 (Auth Service) + // ├── child2 (User Service) + // │ ├── grandchild1 (Database) + // │ └── grandchild2 (Cache Service) + // └── child3 (Notification Service) + // └── grandchild3 (Email Service) + List testSpans = + List.of( + // Root span - API Gateway + TestUtils.createSpanWithTags( + "root", + traceId, + null, + Map.of( + "kube.app", + "api-gateway", + "kube.namespace", + "prod", + "operation_name", + "http.request", + "resource", + "some_resource", + "tag.http.target.canonical_path", + "/api/users/profile", + "tag.http.target.host", + "app1.ns1")), + + // First level children + TestUtils.createSpanWithTags( + "child1", + traceId, + "root", + Map.of( + "kube.app", + "auth-service", + "kube.namespace", + "prod", + "operation_name", + "http.request", + "resource", + "some_resource2", + "tag.http.target.canonical_path", + "/api/auth/validate", + "tag.http.target.host", + "app2.ns2")), + TestUtils.createSpanWithTags( + "child2", + traceId, + "root", + Map.of( + "kube.app", "user-service", + "kube.namespace", "prod", + "operation_name", "user.fetch", + "resource", "getUserProfile")), + TestUtils.createSpanWithTags( + "child3", + traceId, + "root", + Map.of( + "kube.app", "notification-service", + "kube.namespace", "prod", + "operation_name", "notify.send", + "resource", "sendNotification")), + + // Second level children (grandchildren) + TestUtils.createSpanWithTags( + "grandchild1", + traceId, + "child2", + Map.of( + "kube.app", "postgres-db", + "kube.namespace", "prod", + "operation_name", "db.query", + "resource", "SELECT * FROM users WHERE id = ?")), + TestUtils.createSpanWithTags( + "grandchild2", + traceId, + "child2", + Map.of( + "kube.app", "redis-cache", + "kube.namespace", "prod", + "operation_name", "cache.get", + "resource", "user:profile:12345")), + TestUtils.createSpanWithTags( + "grandchild3", + traceId, + "child3", + Map.of( + "kube.app", "email-service", + "kube.namespace", "prod", + "operation_name", "email.send", + "resource", "sendEmail"))); + + when(traceFetcher.getSpansByTraceId( + anyString(), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class))) + .thenReturn(testSpans); + + HttpResponse response = + graphService.getSubgraph(traceId, Optional.empty(), Optional.empty(), Optional.empty()); + AggregatedHttpResponse aggregatedResponse = response.aggregate().join(); + + assertEquals(HttpStatus.OK, aggregatedResponse.status()); + + // Verify it's valid JSON with proper structure + String content = aggregatedResponse.contentUtf8(); + JsonNode jsonNode = objectMapper.readTree(content); + + // Verify structure + assertTrue(jsonNode.has("subgraph")); + assertTrue(jsonNode.has("traceFetchTimeMs")); + assertTrue(jsonNode.has("subgraphBuildTimeMs")); + + JsonNode subgraph = jsonNode.get("subgraph"); + assertTrue(subgraph.has("nodes")); + assertTrue(subgraph.has("edges")); + + // Verify data content + JsonNode nodes = subgraph.get("nodes"); + JsonNode edges = subgraph.get("edges"); + + assertEquals(7, nodes.size(), "Should have 7 nodes (1 root + 3 children + 3 grandchildren)"); + assertEquals( + 6, + edges.size(), + "Should have 6 edges (3 root->child + 2 child2->grandchild + 1 child3->grandchild)"); + + // Verify all nodes have required fields + for (JsonNode node : nodes) { + assertTrue(node.has("id")); + assertTrue(node.has("metadata")); + + JsonNode metadata = node.get("metadata"); + assertTrue(metadata.has("service")); + assertTrue(metadata.has("resource")); + } + + // Verify all edges have required fields + for (JsonNode edge : edges) { + assertTrue(edge.has("sourceNodeId")); + assertTrue(edge.has("targetNodeId")); + + JsonNode metadata = edge.get("metadata"); + assertTrue(metadata.has("operation")); + } + } + + @Test + public void testGetSubgraphByTraceId_withEmptyFilter() throws Exception { + String traceId = "test_trace_empty_filter"; + + List testSpans = + List.of( + TestUtils.createSpanWithTags( + "parent1", + traceId, + null, + Map.of( + "kube.app", "app1", + "kube.namespace", "prod", + "operation_name", "http.request", + "resource", "resource1")), + TestUtils.createSpanWithTags( + "child1", + traceId, + "parent1", + Map.of( + "kube.app", "app2", + "kube.namespace", "prod", + "operation_name", "db.query", + "resource", "resource2"))); + + when(traceFetcher.getSpansByTraceId( + anyString(), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class))) + .thenReturn(testSpans); + + // Empty filter JSON - should return all nodes (no filtering applied) + String emptyFilterJson = "{}"; + + HttpResponse response = + graphService.getSubgraph( + traceId, Optional.of(emptyFilterJson), Optional.empty(), Optional.empty()); + AggregatedHttpResponse aggregatedResponse = response.aggregate().join(); + + assertEquals(HttpStatus.OK, aggregatedResponse.status()); + + String content = aggregatedResponse.contentUtf8(); + JsonNode jsonNode = objectMapper.readTree(content); + + JsonNode subgraph = jsonNode.get("subgraph"); + JsonNode nodes = subgraph.get("nodes"); + JsonNode edges = subgraph.get("edges"); + + // Empty filter should return all nodes and edges + assertEquals(2, nodes.size(), "Empty filter should return all 2 nodes"); + assertEquals(1, edges.size(), "Empty filter should return all 1 edge"); + } + + @Test + public void testGetSubgraphByTraceId_withInvalidFilterJson() throws Exception { + String traceId = "test_trace_invalid_filter"; + + when(traceFetcher.getSpansByTraceId( + anyString(), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class))) + .thenReturn(List.of()); + + // Invalid JSON - missing closing brace + String invalidFilterJson = "{\"operation_name\":[\"http.request\""; + + HttpResponse response = + graphService.getSubgraph( + traceId, Optional.of(invalidFilterJson), Optional.empty(), Optional.empty()); + AggregatedHttpResponse aggregatedResponse = response.aggregate().join(); + + assertEquals(HttpStatus.BAD_REQUEST, aggregatedResponse.status()); + String content = aggregatedResponse.contentUtf8(); + assertTrue(content.contains("Invalid buildFilter JSON")); + } + + @Test + public void testGetSubgraphByTraceId_withMalformedFilterStructure() throws Exception { + String traceId = "test_trace_malformed_filter"; + + when(traceFetcher.getSpansByTraceId( + anyString(), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class))) + .thenReturn(List.of()); + + // Valid JSON but wrong structure - should be Map> not Map + String malformedFilterJson = "{\"operation_name\":\"http.request\"}"; + + HttpResponse response = + graphService.getSubgraph( + traceId, Optional.of(malformedFilterJson), Optional.empty(), Optional.empty()); + AggregatedHttpResponse aggregatedResponse = response.aggregate().join(); + + assertEquals(HttpStatus.BAD_REQUEST, aggregatedResponse.status()); + String content = aggregatedResponse.contentUtf8(); + assertTrue(content.contains("Invalid buildFilter JSON")); + } + + @Test + public void testGetSubgraphByTraceId_withValidFilter() throws Exception { + String traceId = "test_trace_with_filter"; + + // Create spans with different operations + List testSpans = + List.of( + // Root span with http.request + TestUtils.createSpanWithTags( + "root", + traceId, + null, + Map.of( + "kube.app", + "api-gateway", + "kube.namespace", + "prod", + "operation_name", + "http.request", + "resource", + "some_resource", + "tag.http.target.canonical_path", + "/api/endpoint", + "tag.http.target.host", + "gateway.prod")), + // Child with http.request + TestUtils.createSpanWithTags( + "child1", + traceId, + "root", + Map.of( + "kube.app", + "service1", + "kube.namespace", + "prod", + "operation_name", + "http.request", + "resource", + "some_resource2", + "tag.http.target.canonical_path", + "/api/service1", + "tag.http.target.host", + "service1.prod")), + // Child with grpc.request + TestUtils.createSpanWithTags( + "child2", + traceId, + "root", + Map.of( + "kube.app", "service2", + "kube.namespace", "prod", + "operation_name", "grpc.request", + "resource", "grpcMethod")), + // Child with db.query (should be filtered out) + TestUtils.createSpanWithTags( + "child3", + traceId, + "root", + Map.of( + "kube.app", "database", + "kube.namespace", "prod", + "operation_name", "db.query", + "resource", "SELECT * FROM users"))); + + when(traceFetcher.getSpansByTraceId( + anyString(), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class), + any(Optional.class))) + .thenReturn(testSpans); + + // Filter to only include http.request and grpc.request operations + String filterJson = "{\"operation_name\":[\"http.request\",\"grpc.request\"]}"; + + HttpResponse response = + graphService.getSubgraph( + traceId, Optional.of(filterJson), Optional.empty(), Optional.empty()); + AggregatedHttpResponse aggregatedResponse = response.aggregate().join(); + + assertEquals(HttpStatus.OK, aggregatedResponse.status()); + + String content = aggregatedResponse.contentUtf8(); + JsonNode jsonNode = objectMapper.readTree(content); + + JsonNode subgraph = jsonNode.get("subgraph"); + JsonNode nodes = subgraph.get("nodes"); + JsonNode edges = subgraph.get("edges"); + + // Should have 3 nodes (root, child1, child2) - child3 with db.query is filtered out + assertEquals(3, nodes.size(), "Should have 3 nodes after filtering"); + // Should have 2 edges (root->child1, root->child2) + assertEquals(2, edges.size(), "Should have 2 edges after filtering"); } } diff --git a/astra/src/test/resources/test-dependency-graph-config.yaml b/astra/src/test/resources/test-dependency-graph-config.yaml index dc5da17db2..60db4b6e3d 100644 --- a/astra/src/test/resources/test-dependency-graph-config.yaml +++ b/astra/src/test/resources/test-dependency-graph-config.yaml @@ -1,21 +1,27 @@ node_metadata_tag_mapping: - app: - default_key: kube.app - default_value: unknown_app - rules: [] - namespace: - default_key: kube.namespace - default_value: unknown_namespace - rules: [] + service: + default_key: + - kube.app + - kube.namespace + default_value: unknown_service + key_delimiter: . + rules: + - field: operation_name + value: http.request + override_key: + - tag.http.target.host resource: - default_key: resource + default_key: + - resource default_value: unknown_resource rules: - field: operation_name value: http.request - override_key: tag.operation.canonical_path + override_key: + - tag.http.target.canonical_path edge_metadata_tag_mapping: operation: - default_key: operation_name + default_key: + - operation_name default_value: unknown_operation rules: [] \ No newline at end of file