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