diff --git a/src/service/alerts/destinations.rs b/src/service/alerts/destinations.rs
index 98d4405171..c8476f5dca 100644
--- a/src/service/alerts/destinations.rs
+++ b/src/service/alerts/destinations.rs
@@ -481,19 +481,17 @@ mod tests {
template: template1,
..
} = dest1_retrieved.module
- {
- if let Module::Alert {
+ && let Module::Alert {
template: template2,
..
} = dest2_retrieved.module
- {
- assert_eq!(template1, Some("prebuilt_slack".to_string()));
- assert_eq!(template2, Some("prebuilt_slack".to_string()));
- assert_eq!(
- template1, template2,
- "Both destinations should use the same template"
- );
- }
+ {
+ assert_eq!(template1, Some("prebuilt_slack".to_string()));
+ assert_eq!(template2, Some("prebuilt_slack".to_string()));
+ assert_eq!(
+ template1, template2,
+ "Both destinations should use the same template"
+ );
}
// Verify only ONE template exists
diff --git a/src/service/alerts/incidents.rs b/src/service/alerts/incidents.rs
index 2ebd26e288..146712d2c5 100644
--- a/src/service/alerts/incidents.rs
+++ b/src/service/alerts/incidents.rs
@@ -723,32 +723,37 @@ pub async fn enrich_with_topology(
if is_new_node {
let current_node_first_fired = topology.nodes[current_node_index].first_fired_at;
- for (idx, node) in topology.nodes.iter().enumerate() {
- if idx == current_node_index {
- continue; // Skip self
- }
+ // Find the most recent previous node from same service
+ let most_recent_previous = topology
+ .nodes
+ .iter()
+ .enumerate()
+ .filter(|(idx, node)| {
+ *idx != current_node_index
+ && node.service_name == service_name
+ && node.first_fired_at < current_node_first_fired
+ })
+ .max_by_key(|(_, node)| node.first_fired_at);
- // Only connect nodes from same service
- if node.service_name == service_name && node.first_fired_at < current_node_first_fired {
- // Check if edge already exists
- let edge_exists = topology.edges.iter().any(|e| {
- e.from_node_index == idx
- && e.to_node_index == current_node_index
- && matches!(e.edge_type, EdgeType::Temporal)
+ if let Some((prev_idx, _)) = most_recent_previous {
+ // Check if edge already exists
+ let edge_exists = topology.edges.iter().any(|e| {
+ e.from_node_index == prev_idx
+ && e.to_node_index == current_node_index
+ && matches!(e.edge_type, EdgeType::Temporal)
+ });
+
+ if !edge_exists {
+ topology.edges.push(AlertEdge {
+ from_node_index: prev_idx,
+ to_node_index: current_node_index,
+ edge_type: EdgeType::Temporal,
});
-
- if !edge_exists {
- topology.edges.push(AlertEdge {
- from_node_index: idx,
- to_node_index: current_node_index,
- edge_type: EdgeType::Temporal,
- });
- log::debug!(
- "[incidents] Added temporal edge: {} -> {} (same service)",
- idx,
- current_node_index
- );
- }
+ log::debug!(
+ "[incidents] Added temporal edge: {} -> {} (most recent from same service)",
+ prev_idx,
+ current_node_index
+ );
}
}
}
@@ -796,42 +801,71 @@ pub async fn enrich_with_topology(
.iter()
.any(|e| &e.from == from_service && &e.to == to_service);
- // Add edge from each node in from_service to each node in to_service
- // that occurred chronologically after
- for &from_idx in from_indices {
- for &to_idx in to_indices {
+ if has_sg_edge {
+ // Service dependency: connect ALL nodes (full causality)
+ for &from_idx in from_indices {
+ for &to_idx in to_indices {
+ let from_node = &topology.nodes[from_idx];
+ let to_node = &topology.nodes[to_idx];
+
+ // Only add if from happened before to (chronological)
+ if from_node.first_fired_at < to_node.first_fired_at {
+ // Check if edge already exists
+ let edge_exists = topology.edges.iter().any(|e| {
+ e.from_node_index == from_idx
+ && e.to_node_index == to_idx
+ && matches!(e.edge_type, EdgeType::ServiceDependency)
+ });
+
+ if !edge_exists {
+ topology.edges.push(AlertEdge {
+ from_node_index: from_idx,
+ to_node_index: to_idx,
+ edge_type: EdgeType::ServiceDependency,
+ });
+ log::debug!(
+ "[incidents] Added ServiceDependency edge: {from_idx} ({from_service}) -> {to_idx} ({to_service})"
+ );
+ }
+ }
+ }
+ }
+ } else {
+ // Temporal only: connect to most recent node in target service
+ // For each node in from_service, find the most recent
+ // chronologically-after node in to_service
+ for &from_idx in from_indices {
let from_node = &topology.nodes[from_idx];
- let to_node = &topology.nodes[to_idx];
- // Only add if from happened before to (chronological)
- if from_node.first_fired_at < to_node.first_fired_at {
- // Determine edge type: ServiceDependency if SG edge exists, otherwise
- // Temporal
- let edge_type = if has_sg_edge {
- EdgeType::ServiceDependency
- } else {
- EdgeType::Temporal
- };
+ // Find most recent node in to_service that fired after from_node
+ let most_recent_to = to_indices
+ .iter()
+ .filter_map(|&to_idx| {
+ let to_node = &topology.nodes[to_idx];
+ if to_node.first_fired_at > from_node.first_fired_at {
+ Some((to_idx, to_node.first_fired_at))
+ } else {
+ None
+ }
+ })
+ .min_by_key(|(_, fired_at)| *fired_at); // Closest in time (earliest after from_node)
+ if let Some((to_idx, _)) = most_recent_to {
// Check if edge already exists
let edge_exists = topology.edges.iter().any(|e| {
e.from_node_index == from_idx
&& e.to_node_index == to_idx
- && matches!(
- (&e.edge_type, &edge_type),
- (EdgeType::ServiceDependency, EdgeType::ServiceDependency)
- | (EdgeType::Temporal, EdgeType::Temporal)
- )
+ && matches!(e.edge_type, EdgeType::Temporal)
});
if !edge_exists {
topology.edges.push(AlertEdge {
from_node_index: from_idx,
to_node_index: to_idx,
- edge_type,
+ edge_type: EdgeType::Temporal,
});
log::debug!(
- "[incidents] Added {edge_type:?} edge: {from_idx} ({from_service}) -> {to_idx} ({to_service})"
+ "[incidents] Added Temporal edge: {from_idx} ({from_service}) -> {to_idx} ({to_service}, most recent)"
);
}
}
diff --git a/src/service/promql/search/cache/mod.rs b/src/service/promql/search/cache/mod.rs
index 8a596e46a2..14c2f78062 100644
--- a/src/service/promql/search/cache/mod.rs
+++ b/src/service/promql/search/cache/mod.rs
@@ -681,21 +681,9 @@ mod tests {
assert!(cap1 >= len1);
// Values may change due to concurrent tests, but should not vary wildly
- let len_diff = if len2 > len1 {
- len2 - len1
- } else {
- len1 - len2
- };
- let cap_diff = if cap2 > cap1 {
- cap2 - cap1
- } else {
- cap1 - cap2
- };
- let mem_diff = if mem2 > mem1 {
- mem2 - mem1
- } else {
- mem1 - mem2
- };
+ let len_diff = len2.abs_diff(len1);
+ let cap_diff = cap2.abs_diff(cap1);
+ let mem_diff = mem2.abs_diff(mem1);
assert!(len_diff < 1000 || len1 == 0 || len2 == 0);
assert!(cap_diff < 10000 || cap1 == 0 || cap2 == 0);
assert!(mem_diff < 1000000 || mem1 == 0 || mem2 == 0);
diff --git a/web/src/components/alerts/IncidentDetailDrawer.vue b/web/src/components/alerts/IncidentDetailDrawer.vue
index 348eef9d99..978fe2e4ae 100644
--- a/web/src/components/alerts/IncidentDetailDrawer.vue
+++ b/web/src/components/alerts/IncidentDetailDrawer.vue
@@ -1010,8 +1010,8 @@ along with this program. If not, see .
-
-
+
+
Showing correlated logs from incident timeframe
.
Loading correlated logs...
-
-
-
-
Failed to load correlated logs
-
{{ correlationError }}
+
+
+
+
+ {{ correlationError || 'No correlated logs found' }}
+
+
+ The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
+
-
-
-
-
No correlated logs found
-
- Try searching manually using these dimensions:
-
-
-
- {{ key }}:
- {{ value }}
- { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
-
-
-
-
.
-
-
+
+
Showing correlated metrics from incident timeframe
.
Loading correlated metrics...
-
-
-
-
Failed to load correlated metrics
-
{{ correlationError }}
+
+
+
+
+ {{ correlationError || 'No correlated metrics found' }}
+
+
+ The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
+
-
-
-
-
No correlated metrics found
-
- Try searching manually using these dimensions:
-
-
-
- {{ key }}:
- {{ value }}
- { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
-
-
-
-
.
-
-
+
+
Showing correlated traces from incident timeframe
.
Loading correlated traces...
-
-
-
-
Failed to load correlated traces
-
{{ correlationError }}
+
+
+
+
+ {{ correlationError || 'No correlated traces found' }}
+
+
+ The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
+
-
-
-
-
No correlated traces found
-
- Try searching manually using these dimensions:
-
-
-
- {{ key }}:
- {{ value }}
- { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
-
-
-
-
incidentDetails.value.created_at) {
+ correlationError.value =
+ "FQN priority was modified since this incident was created, cannot correlate";
+ console.warn(
+ `[Incident Correlation] FQN config updated at ${fqnConfig.updated_at}, incident created at ${incidentDetails.value.created_at}`
+ );
+ } else {
+ // Not a config issue - try building fallback correlation
+ await buildFallbackCorrelation(org, incidentDetails.value);
+ }
+ } catch (configError) {
+ // If can't fetch config, try fallback anyway
+ console.error("Failed to fetch FQN config:", configError);
+ await buildFallbackCorrelation(org, incidentDetails.value);
+ }
+ }
} catch (error: any) {
console.error("Failed to load correlated streams:", error);
correlationError.value =
@@ -1529,6 +1544,136 @@ export default defineComponent({
}
};
+ // Build fallback correlation using first alert's stream schema
+ const buildFallbackCorrelation = async (org: string, incident: Incident) => {
+ try {
+ console.log("[Fallback Correlation] Building local correlation from first alert's stream");
+
+ // Get first alert to determine source stream
+ const firstAlert = alerts.value?.[0];
+ if (!firstAlert) {
+ console.warn("[Fallback Correlation] No alerts found in incident");
+ return;
+ }
+
+ // Use actual stream type and name from the alert
+ const streamType = firstAlert.stream_type || "logs";
+ const streamName = firstAlert.stream_name || "default";
+
+ // Get stream schema
+ const schemaResponse = await streamService.schema(org, streamName, streamType);
+ const schema = schemaResponse.data;
+
+ // Get semantic groups
+ const semanticGroupsResponse = await serviceStreamsApi.getSemanticGroups(org);
+ const semanticGroups = semanticGroupsResponse.data;
+
+ // Map incident dimensions to field names in schema
+ // Schema response has .schema array with {name, type} objects
+ const schemaFields = new Set(
+ (schema.schema || schema.fields || []).map((f: any) => f.name)
+ );
+
+ console.log("[Fallback Correlation] Incident dimensions:", incident.stable_dimensions);
+ console.log("[Fallback Correlation] Total schema fields:", schemaFields.size);
+ console.log("[Fallback Correlation] Sample schema fields:", Array.from(schemaFields).slice(0, 10));
+
+ const filters: Record = {};
+
+ for (const [dimId, dimValue] of Object.entries(incident.stable_dimensions)) {
+ // Find semantic group
+ const group = semanticGroups.find((g: any) => g.id === dimId);
+
+ console.log(`[Fallback Correlation] Processing ${dimId} = ${dimValue}`);
+
+ if (!group) {
+ console.warn(`[Fallback Correlation] No semantic group found for: ${dimId}`);
+ continue;
+ }
+
+ if (!group.fields || group.fields.length === 0) {
+ console.warn(`[Fallback Correlation] Semantic group ${dimId} has empty fields array`);
+ continue;
+ }
+
+ console.log(`[Fallback Correlation] Trying field variants for ${dimId}:`, group.fields.slice(0, 5));
+
+ // Collect ALL matching field names and pick the best one
+ const matchingFields = [];
+ for (const fieldName of group.fields) {
+ if (schemaFields.has(fieldName)) {
+ matchingFields.push(fieldName);
+ }
+ }
+
+ if (matchingFields.length > 0) {
+ // Prefer shorter field names without prefixes
+ const bestField = matchingFields.sort((a, b) => {
+ // Penalize long prefixes
+ const aPenalty = a.startsWith('service_') ? 1000 :
+ a.startsWith('resource_') ? 500 :
+ a.startsWith('attributes_') ? 300 : 0;
+ const bPenalty = b.startsWith('service_') ? 1000 :
+ b.startsWith('resource_') ? 500 :
+ b.startsWith('attributes_') ? 300 : 0;
+
+ // Then by length (shorter = better)
+ return (a.length + aPenalty) - (b.length + bPenalty);
+ })[0];
+
+ filters[bestField] = dimValue;
+ console.log(`[Fallback Correlation] ✅ Mapped ${dimId} → ${bestField} = ${dimValue} (from ${matchingFields.length} options: ${matchingFields.slice(0, 3).join(', ')})`);
+ } else {
+ console.warn(`[Fallback Correlation] ❌ No matching field in schema for ${dimId}`);
+ }
+ }
+
+ if (Object.keys(filters).length === 0) {
+ console.warn("[Fallback Correlation] No dimensions could be mapped to stream fields");
+ return;
+ }
+
+ console.log("[Fallback Correlation] Mapped filters:", filters);
+ console.log("[Fallback Correlation] Schema fields:", Array.from(schemaFields));
+ console.log("[Fallback Correlation] Stream type:", streamType);
+
+ // Build StreamInfo object
+ const streamInfo = {
+ stream_name: streamName,
+ stream_type: streamType === 'logs' ? 'Logs' : streamType === 'metrics' ? 'Metrics' : 'Traces',
+ filters
+ };
+
+ // Build correlation response with only the source stream type
+ correlationData.value = {
+ serviceName: `dimension-match-${incident.stable_dimensions.service || 'unknown'}`,
+ matchedDimensions: incident.stable_dimensions,
+ additionalDimensions: {},
+ logStreams: streamType === 'logs' ? [streamInfo] : [],
+ metricStreams: streamType === 'metrics' ? [streamInfo] : [],
+ traceStreams: streamType === 'traces' ? [streamInfo] : [],
+ correlationData: {
+ service_name: `dimension-match-${incident.stable_dimensions.service || 'unknown'}`,
+ matched_dimensions: incident.stable_dimensions,
+ additional_dimensions: {},
+ related_streams: {
+ logs: streamType === 'logs' ? [streamInfo] : [],
+ metrics: streamType === 'metrics' ? [streamInfo] : [],
+ traces: streamType === 'traces' ? [streamInfo] : [],
+ },
+ correlation_method: "frontend-fallback"
+ }
+ };
+
+ console.log("[Fallback Correlation] Built correlation data:", correlationData.value);
+ console.log("[Fallback Correlation] Log streams:", correlationData.value.logStreams);
+ console.log("[Fallback Correlation] Filters being passed:", correlationData.value.logStreams[0]?.filters);
+ } catch (fallbackError) {
+ console.error("[Fallback Correlation] Failed to build fallback:", fallbackError);
+ // Don't set error - let tabs show "No correlated X found"
+ }
+ };
+
// Refresh correlation data
const refreshCorrelation = () => {
fetchCorrelatedStreams(true);
diff --git a/web/src/components/alerts/IncidentServiceGraph.spec.ts b/web/src/components/alerts/IncidentServiceGraph.spec.ts
index 024552eb52..6a70b6d12f 100644
--- a/web/src/components/alerts/IncidentServiceGraph.spec.ts
+++ b/web/src/components/alerts/IncidentServiceGraph.spec.ts
@@ -432,9 +432,10 @@ describe("IncidentServiceGraph.vue", () => {
const node2 = chartData.options.series[0].data[1]; // 7 alerts
const node3 = chartData.options.series[0].data[2]; // 12 alerts
- expect(node1.symbolSize).toBe(45); // 30 + 3*5
- expect(node2.symbolSize).toBe(65); // 30 + 7*5
- expect(node3.symbolSize).toBe(90); // 30 + 12*5
+ // All nodes have fixed size of 60
+ expect(node1.symbolSize).toBe(60);
+ expect(node2.symbolSize).toBe(60);
+ expect(node3.symbolSize).toBe(60);
});
it("should cap node size at 100", async () => {
@@ -463,7 +464,7 @@ describe("IncidentServiceGraph.vue", () => {
const chartData = wrapper.vm.chartData;
const largeNode = chartData.options.series[0].data[0];
- expect(largeNode.symbolSize).toBe(100); // Capped at 100
+ expect(largeNode.symbolSize).toBe(60); // Fixed size for all nodes
});
it("should add border to primary service nodes", async () => {
@@ -839,7 +840,7 @@ describe("IncidentServiceGraph.vue", () => {
const chartData = wrapper.vm.chartData;
// Second node has 0 alerts
const zeroAlertNode = chartData.options.series[0].data[1];
- expect(zeroAlertNode.symbolSize).toBe(30); // Base size
+ expect(zeroAlertNode.symbolSize).toBe(60); // Fixed size for all nodes
});
it("should handle very large alert counts", async () => {
@@ -867,7 +868,7 @@ describe("IncidentServiceGraph.vue", () => {
const chartData = wrapper.vm.chartData;
const largeNode = chartData.options.series[0].data[0];
- expect(largeNode.symbolSize).toBe(100); // Capped at 100
+ expect(largeNode.symbolSize).toBe(60); // Fixed size for all nodes
});
it("should handle null orgId gracefully", () => {
diff --git a/web/src/components/alerts/IncidentServiceGraph.vue b/web/src/components/alerts/IncidentServiceGraph.vue
index 78e9e8692d..c5601f75b6 100644
--- a/web/src/components/alerts/IncidentServiceGraph.vue
+++ b/web/src/components/alerts/IncidentServiceGraph.vue
@@ -15,7 +15,43 @@ along with this program. If not, see .
-->
-
+
+
+
+
+
+
Graph Legend
+
+
+ ● Red = Potential Root Cause
+
+
+ ● Orange = High Frequency
+
+
+ ● Blue = Normal
+
+
+ → Purple arrows show temporal flow
+
+
+
+
+
props.topologyContext);
- // D3-Force simulation to compute stable node positions
+ // D3-Force simulation to compute stable node positions with left-to-right layout
const computeForceLayout = (nodes: any[], edges: any[], width = 800, height = 600) => {
const nodesCopy = nodes.map(n => ({ ...n }));
const edgesCopy = edges.map(e => ({
@@ -98,23 +134,74 @@ export default defineComponent({
...e,
}));
+ // Calculate depth/level for each node (for left-to-right positioning)
+ const nodeDepth = new Map
();
+ nodesCopy.forEach(n => nodeDepth.set(n.id, 0));
+
+ // Build adjacency list from temporal edges to determine hierarchy
+ const temporalEdges = edgesCopy.filter(e => e.originalEdge?.edge_type === 'temporal');
+ const visited = new Set();
+
+ // BFS to calculate depth
+ const queue: Array<{ id: string; depth: number }> = [];
+
+ // Find root nodes (nodes with no incoming temporal edges)
+ const hasIncoming = new Set(temporalEdges.map(e => typeof e.target === 'string' ? e.target : e.target.id));
+ nodesCopy.forEach(n => {
+ if (!hasIncoming.has(n.id)) {
+ queue.push({ id: n.id, depth: 0 });
+ }
+ });
+
+ while (queue.length > 0) {
+ const { id, depth } = queue.shift()!;
+ if (visited.has(id)) continue;
+ visited.add(id);
+ nodeDepth.set(id, depth);
+
+ // Find outgoing temporal edges
+ temporalEdges.forEach(edge => {
+ const sourceId = typeof edge.source === 'string' ? edge.source : edge.source.id;
+ const targetId = typeof edge.target === 'string' ? edge.target : edge.target.id;
+ if (sourceId === id) {
+ queue.push({ id: targetId, depth: depth + 1 });
+ }
+ });
+ }
+
const simulation = forceSimulation(nodesCopy)
- .force('charge', forceManyBody().strength(-200).distanceMax(1200)) // Minimal repulsion for very tight clustering
+ .force('charge', forceManyBody().strength(-400).distanceMax(1200))
.force('link', forceLink(edgesCopy)
.id((d: any) => d.id)
- .distance(100) // Reduced from 250 to 100 for much closer nodes
- .strength(0.8) // Increased from 0.6 to pull nodes together more
- .iterations(3)
- )
- .force('center', forceCenter(width / 2, height / 2).strength(0.1)) // Increased from 0.05 for stronger centering
- .force('x', forceX(width / 2).strength(0.15)) // Increased for tighter clustering
- .force('y', forceY(height / 2).strength(0.15)) // Increased for tighter clustering
- .force('collision', forceCollide()
- .radius((d: any) => (d.symbolSize || 60) / 2 + 30) // Reduced padding from 80 to 30
- .strength(1.0)
+ .distance(180)
+ .strength(0.5)
.iterations(2)
)
- .velocityDecay(0.35)
+ .force('x', forceX((d: any) => {
+ // Position nodes left-to-right based on their depth, starting from extreme left
+ const depth = nodeDepth.get(d.id) || 0;
+ const maxDepth = Math.max(...Array.from(nodeDepth.values()));
+ const leftMargin = 80; // Left margin to prevent nodes from touching the edge
+ const rightMargin = 80; // Right margin
+ const availableWidth = width - leftMargin - rightMargin;
+ const spacing = maxDepth > 0 ? availableWidth / maxDepth : 0;
+ return leftMargin + spacing * depth;
+ }).strength(1.5)) // Strong horizontal positioning
+ .force('y', forceY((d: any) => {
+ // Stronger vertical centering for root nodes (depth 0)
+ const depth = nodeDepth.get(d.id) || 0;
+ return height / 2;
+ }).strength((d: any) => {
+ // Stronger centering for root nodes
+ const depth = nodeDepth.get(d.id) || 0;
+ return depth === 0 ? 0.8 : 0.1; // Much stronger centering for root nodes
+ }))
+ .force('collision', forceCollide()
+ .radius((d: any) => (d.symbolSize || 60) / 2 + 50)
+ .strength(1.0)
+ .iterations(3)
+ )
+ .velocityDecay(0.4)
.stop();
// Run simulation for 5000 iterations to stabilize
@@ -143,8 +230,7 @@ export default defineComponent({
};
const getNodeSize = (node: AlertNode): number => {
- const size = 30 + node.alert_count * 5;
- return Math.min(size, 100); // Cap at 100 to prevent oversized nodes
+ return 60; // Fixed size for all nodes
};
const chartData = computed(() => {
@@ -156,7 +242,7 @@ export default defineComponent({
// Prepare nodes for D3-force simulation
const preparedNodes = nodes.map((node, index) => ({
- name: `${node.alert_name}\n${node.service_name}`,
+ name: node.alert_name, // Show only alert name for cleaner labels
id: index.toString(),
symbolSize: getNodeSize(node),
originalNode: node,
@@ -170,7 +256,7 @@ export default defineComponent({
originalEdge: edge,
}));
- // Compute force-directed layout positions using D3 with smaller area for tighter clustering
+ // Compute force-directed layout positions using D3 with left-to-right layout
// Only compute if we don't have cached positions for these nodes
let positionedNodes;
const hasAllPositions = preparedNodes.every(n => nodePositions.value.has(n.id));
@@ -183,8 +269,8 @@ export default defineComponent({
y: nodePositions.value.get(n.id)!.y,
}));
} else {
- // Compute new positions and cache them
- positionedNodes = computeForceLayout(preparedNodes, preparedEdges, 300, 300);
+ // Compute new positions and cache them with wider canvas for left-to-right layout
+ positionedNodes = computeForceLayout(preparedNodes, preparedEdges, 1200, 400);
positionedNodes.forEach((node: any) => {
nodePositions.value.set(node.id, { x: node.x, y: node.y });
});
@@ -214,6 +300,11 @@ export default defineComponent({
fontSize: 11,
color: isDarkMode.value ? "#e5e7eb" : "#374151",
formatter: `{b}`,
+ backgroundColor: isDarkMode.value ? "rgba(31, 41, 55, 0.9)" : "rgba(255, 255, 255, 0.9)",
+ borderRadius: 4,
+ padding: [4, 8],
+ shadowColor: "rgba(0, 0, 0, 0.3)",
+ shadowBlur: 4,
},
tooltip: {
formatter: () => {
@@ -238,26 +329,62 @@ export default defineComponent({
};
});
- const echartsEdges = edges.map((edge) => ({
- source: edge.from_node_index.toString(),
- target: edge.to_node_index.toString(),
- lineStyle: {
- color: edge.edge_type === "temporal"
- ? (isDarkMode.value ? "#a78bfa" : "#8b5cf6") // purple for temporal
- : (isDarkMode.value ? "#6b7280" : "#9ca3af"), // gray for service dependency
- width: edge.edge_type === "temporal" ? 3 : 2,
- curveness: 0.2,
- type: edge.edge_type === "temporal" ? "dashed" : "solid",
- },
- symbol: ["none", "arrow"],
- symbolSize: [0, 10],
- label: {
- show: true,
- formatter: edge.edge_type === "temporal" ? "time →" : "",
- fontSize: 10,
- color: isDarkMode.value ? "#a78bfa" : "#8b5cf6",
- },
- }));
+ const echartsEdges = edges.map((edge) => {
+ const sourceNode = nodes[edge.from_node_index];
+ const targetNode = nodes[edge.to_node_index];
+
+ return {
+ source: edge.from_node_index.toString(),
+ target: edge.to_node_index.toString(),
+ lineStyle: {
+ color: edge.edge_type === "temporal"
+ ? (isDarkMode.value ? "#a78bfa" : "#8b5cf6") // purple for temporal
+ : (isDarkMode.value ? "#6b7280" : "#9ca3af"), // gray for service dependency
+ width: edge.edge_type === "temporal" ? 3 : 2,
+ curveness: 0.2,
+ type: "solid",
+ },
+ symbol: ["none", "arrow"],
+ symbolSize: [0, 10],
+ label: {
+ show: false, // Hide edge labels for cleaner visualization
+ },
+ tooltip: {
+ formatter: () => {
+ let html = ``;
+ html += `${sourceNode.alert_name} → ${targetNode.alert_name}
`;
+
+ if (edge.edge_type === "temporal") {
+ const sourceTime = new Date(sourceNode.first_fired_at / 1000);
+ const targetTime = new Date(targetNode.first_fired_at / 1000);
+ const timeDiff = Math.abs(targetTime.getTime() - sourceTime.getTime());
+
+ // Format time difference
+ const seconds = Math.floor(timeDiff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ let timeStr = "";
+ if (days > 0) timeStr = `${days}d ${hours % 24}h`;
+ else if (hours > 0) timeStr = `${hours}h ${minutes % 60}m`;
+ else if (minutes > 0) timeStr = `${minutes}m ${seconds % 60}s`;
+ else timeStr = `${seconds}s`;
+
+ html += `⏱ Time difference: ${timeStr}
`;
+ html += `From: ${sourceTime.toLocaleString()}
`;
+ html += `To: ${targetTime.toLocaleString()}
`;
+ html += `
Temporal correlation`;
+ } else {
+ html += `Service dependency`;
+ }
+
+ html += `
`;
+ return html;
+ },
+ },
+ };
+ });
const options = {
tooltip: {
@@ -334,6 +461,18 @@ export default defineComponent({
transition: all 0.2s ease;
}
+.info-icon-btn {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ z-index: 10;
+ transition: all 0.2s ease;
+}
+
+.info-icon-btn:hover {
+ transform: scale(1.1);
+}
+
/* Light mode */
.incident-service-graph {
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
diff --git a/web/src/composables/useMetricsCorrelationDashboard.spec.ts b/web/src/composables/useMetricsCorrelationDashboard.spec.ts
index ad4cb4d6da..a4e8150858 100644
--- a/web/src/composables/useMetricsCorrelationDashboard.spec.ts
+++ b/web/src/composables/useMetricsCorrelationDashboard.spec.ts
@@ -158,6 +158,13 @@ describe("useMetricsCorrelationDashboard", () => {
});
it("should use source stream when coming from logs page", () => {
+ const logStreams: StreamInfo[] = [
+ {
+ stream_name: "default",
+ filters: { service_name: "api", env: "prod" },
+ },
+ ];
+
const config: MetricsCorrelationConfig = {
serviceName: "api-server",
matchedDimensions: { service: "api", environment: "prod" },
@@ -176,17 +183,17 @@ describe("useMetricsCorrelationDashboard", () => {
},
};
- const dashboard = composable.generateLogsDashboard([], config);
+ const dashboard = composable.generateLogsDashboard(logStreams, config);
expect(dashboard).toBeDefined();
expect(dashboard!.tabs[0].panels[0].queries[0].fields.stream).toBe("default");
- // Should only use matched dimension values, not all available fields
+ // Should only use matched dimension values from API filters
const query = dashboard!.tabs[0].panels[0].queries[0].query;
expect(query).toContain('FROM "default"');
expect(query).toContain("service_name = 'api'");
expect(query).toContain("env = 'prod'");
- expect(query).not.toContain("host = 'server01'"); // Not a matched dimension
+ expect(query).not.toContain("host = 'server01'"); // Not in API filters
});
it("should use correlated log streams from API", () => {
@@ -234,6 +241,18 @@ describe("useMetricsCorrelationDashboard", () => {
});
it("should filter out non-string values from filters", () => {
+ const logStreams: StreamInfo[] = [
+ {
+ stream_name: "default",
+ filters: {
+ service_name: "api",
+ port: 8080 as any, // Non-string value
+ enabled: true as any, // Non-string value
+ _timestamp: 123456 as any, // Internal field
+ },
+ },
+ ];
+
const config: MetricsCorrelationConfig = {
serviceName: "api-server",
matchedDimensions: { service: "api" },
@@ -245,15 +264,9 @@ describe("useMetricsCorrelationDashboard", () => {
},
sourceStream: "default",
sourceType: "logs",
- availableDimensions: {
- service_name: "api",
- port: 8080 as any, // Non-string value
- enabled: true as any, // Non-string value
- _timestamp: 123456 as any, // Internal field
- },
};
- const dashboard = composable.generateLogsDashboard([], config);
+ const dashboard = composable.generateLogsDashboard(logStreams, config);
const query = dashboard!.tabs[0].panels[0].queries[0].query;
// Should only include service_name (string, non-internal) in WHERE clause
diff --git a/web/src/composables/useMetricsCorrelationDashboard.ts b/web/src/composables/useMetricsCorrelationDashboard.ts
index f1adccaa51..c1a22763e0 100644
--- a/web/src/composables/useMetricsCorrelationDashboard.ts
+++ b/web/src/composables/useMetricsCorrelationDashboard.ts
@@ -279,57 +279,28 @@ ORDER BY x_axis_1`;
streams: StreamInfo[],
config: MetricsCorrelationConfig
) => {
- // When coming from logs page, use the source stream (the one user was viewing)
- // Otherwise, use the first correlated log stream from API
+ // Determine stream and filters based on available data
let streamName: string;
let filters: Record;
if (config.sourceType === "logs" && config.sourceStream) {
- // Use the original logs stream user was viewing
+ // When viewing from logs page, prefer source stream
streamName = config.sourceStream;
- // Use only the matched dimensions from availableDimensions
- // Extract only the fields that correspond to matched dimension keys
- filters = {};
- if (config.availableDimensions && config.matchedDimensions) {
- // Build a reverse mapping: value -> field name from availableDimensions
- const valueToFieldMap = new Map();
- for (const [fieldName, fieldValue] of Object.entries(config.availableDimensions)) {
- if (typeof fieldValue === 'string') {
- valueToFieldMap.set(String(fieldValue), fieldName);
- }
- }
-
- // For each matched dimension, find the actual field name
- for (const [semanticKey, value] of Object.entries(config.matchedDimensions)) {
- if (value === "_o2_all_") {
- // For _o2_all_, we need to find the field name by matching against the semantic key
- // Try exact match first with underscores
- const normalizedKey = semanticKey.replace(/-/g, '_');
- if (config.availableDimensions[normalizedKey] !== undefined) {
- filters[normalizedKey] = "_o2_all_";
- } else {
- // Try to find by partial match
- for (const fieldName of Object.keys(config.availableDimensions)) {
- if (fieldName.toLowerCase() === normalizedKey.toLowerCase()) {
- filters[fieldName] = "_o2_all_";
- break;
- }
- }
- }
- } else {
- // For actual values, use the reverse mapping
- const fieldName = valueToFieldMap.get(value);
- if (fieldName) {
- filters[fieldName] = value;
- }
- }
- }
+ // Try to find matching stream in API response
+ const matchingStream = streams?.find(s => s.stream_name === config.sourceStream);
+ if (matchingStream) {
+ // Use filters from API response (best case - backend computed correct field names)
+ filters = matchingStream.filters;
+ } else if (streams && streams.length > 0) {
+ // Source stream not in response, use first available stream's filters
+ filters = streams[0].filters;
} else {
- filters = config.matchedDimensions;
+ // No streams from API, fallback to matched dimensions
+ filters = config.matchedDimensions || {};
}
} else if (streams && streams.length > 0) {
- // Use correlated log streams from API response
+ // Use first correlated log stream from API response
const primaryStream = streams[0];
streamName = primaryStream.stream_name;
filters = primaryStream.filters;
diff --git a/web/src/plugins/traces/ServiceGraph.vue b/web/src/plugins/traces/ServiceGraph.vue
index a897870537..b3d7038f7e 100644
--- a/web/src/plugins/traces/ServiceGraph.vue
+++ b/web/src/plugins/traces/ServiceGraph.vue
@@ -8,10 +8,10 @@
-
- No streams detected. Ensure service graph metrics include
- stream_name labels.
+
+ No streams detected. Loading streams from parent...
@@ -54,12 +53,12 @@
- Service graph is disabled
-
-
- Enable the feature by setting environment variable:
-
-
O2_SERVICE_GRAPH_ENABLED=true
-
-
-
-
- Query time range is too small
-
-
- The daemon queries traces within a time window. If your
- traces are older, increase the window:
-
-
O2_SERVICE_GRAPH_QUERY_TIME_RANGE_MINUTES=120
-
- (Default: 60 minutes)
+ Queried time range has no traces, try selecting a longer duration.
@@ -233,74 +180,12 @@
class="text-weight-medium q-mb-sm"
style="font-size: 1rem"
>
- Only INTERNAL spans detected
-
-
- Your traces have
- span_kind=1
- (INTERNAL operations within a service).
+ Maybe your traces have only internal spans (span_kind = 1)?
- To create service-to-service edges, send traces with:
-
-
- -
- CLIENT spans (
span_kind=3) with
- peer.service
- attribute, or
-
- -
- SERVER spans (
span_kind=2) receiving requests from other services
-
-
-
- Note: INTERNAL spans can be excluded by setting
- O2_SERVICE_GRAPH_EXCLUDE_INTERNAL_SPANS=true
+ Make sure O2_SERVICE_GRAPH_EXCLUDE_INTERNAL_SPANS=true
+ to process internal spans for service edges.
(null);
@@ -416,12 +303,20 @@ export default defineComponent({
const searchFilter = ref("");
const connectionTypeFilter = ref("all");
- // Stream filter
- const storedStreamFilter = localStorage.getItem(
- "serviceGraph_streamFilter",
- );
- const streamFilter = ref(storedStreamFilter || "all");
- const availableStreams = ref([]);
+ // Stream filter - use computed property to read from and write to shared state
+ const streamFilter = computed({
+ get: () => {
+ const selectedValue = searchObj.data.stream.selectedStream?.value;
+ return selectedValue || "all";
+ },
+ set: (value) => {
+ searchObj.data.stream.selectedStream = {
+ label: value === "all" ? "" : value,
+ value: value === "all" ? "" : value,
+ };
+ },
+ });
+ // availableStreams removed - now using searchObj.data.stream.streamLists directly
// Connection type tabs configuration
const connectionTypeTabs = [
@@ -445,13 +340,8 @@ export default defineComponent({
const stats = ref(null);
- // Time range state - default to last 15 minutes
- const timeRange = ref({
- type: "relative",
- relativeTimePeriod: "15m",
- startTime: Date.now() * 1000 - 15 * 60 * 1000000, // 15 minutes ago in microseconds
- endTime: Date.now() * 1000, // Now in microseconds
- });
+ // Use shared datetime from searchObj instead of local timeRange
+ // searchObj.data.datetime is managed by useTraces composable and shared across tabs
const dateTimeRef = ref(null);
@@ -538,27 +428,7 @@ export default defineComponent({
throw new Error("No organization selected");
}
- // If a specific stream is selected but we don't have available streams yet,
- // first fetch all streams to populate the dropdown
- if (
- availableStreams.value.length === 0 &&
- streamFilter.value !== "all"
- ) {
- console.log(
- "[ServiceGraph] Fetching all streams first to populate dropdown",
- );
- const allStreamsResponse =
- await serviceGraphService.getCurrentTopology(orgId, {
- startTime: timeRange.value.startTime,
- endTime: timeRange.value.endTime,
- });
- if (
- allStreamsResponse.data.availableStreams &&
- allStreamsResponse.data.availableStreams.length > 0
- ) {
- availableStreams.value = allStreamsResponse.data.availableStreams;
- }
- }
+ // Streams are loaded by parent Index.vue and available in searchObj.data.stream.streamLists
// Stream-only implementation - no store stats needed
// Use JSON topology endpoint with time range
@@ -567,8 +437,8 @@ export default defineComponent({
streamFilter.value && streamFilter.value !== "all"
? streamFilter.value
: undefined,
- startTime: timeRange.value.startTime,
- endTime: timeRange.value.endTime,
+ startTime: searchObj.data.datetime.startTime,
+ endTime: searchObj.data.datetime.endTime,
});
// Convert API response to expected format
@@ -767,7 +637,11 @@ export default defineComponent({
};
const onStreamFilterChange = (stream: string) => {
- streamFilter.value = stream;
+ // Update shared stream selection
+ searchObj.data.stream.selectedStream = {
+ label: stream === "all" ? "" : stream,
+ value: stream === "all" ? "" : stream,
+ };
localStorage.setItem("serviceGraph_streamFilter", stream);
// Reload service graph with new stream filter
@@ -845,31 +719,25 @@ export default defineComponent({
};
const updateTimeRange = (value: any) => {
- timeRange.value = {
+ // Update shared datetime state
+ searchObj.data.datetime = {
startTime: value.startTime,
endTime: value.endTime,
relativeTimePeriod:
- value.relativeTimePeriod || timeRange.value.relativeTimePeriod,
+ value.relativeTimePeriod || searchObj.data.datetime.relativeTimePeriod,
type: value.relativeTimePeriod ? "relative" : "absolute",
+ queryRangeRestrictionMsg: searchObj.data.datetime?.queryRangeRestrictionMsg || "",
+ queryRangeRestrictionInHour: searchObj.data.datetime?.queryRangeRestrictionInHour || 0,
};
// Reload service graph with new time range
loadServiceGraph();
};
// Load trace streams using the same method as the Traces search page
- const loadTraceStreams = async () => {
- try {
- const res = await getStreams("traces", false, false);
- if (res?.list?.length > 0) {
- availableStreams.value = res.list.map((stream: any) => stream.name);
- }
- } catch (e) {
- console.error("Error loading trace streams:", e);
- }
- };
+ // Streams are loaded by parent Index.vue into searchObj.data.stream.streamLists
+ // No need to load them again here
- onMounted(async () => {
- await loadTraceStreams();
+ onMounted(() => {
loadServiceGraph();
});
@@ -883,7 +751,6 @@ export default defineComponent({
lastUpdated,
searchFilter,
streamFilter,
- availableStreams,
connectionTypeFilter,
connectionTypeTabs,
visualizationType,
@@ -892,7 +759,7 @@ export default defineComponent({
chartData,
chartKey,
chartRendererRef,
- timeRange,
+ searchObj,
dateTimeRef,
loadServiceGraph,
formatNumber,
@@ -934,5 +801,7 @@ code {
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 0.9em;
+ font-weight: 600;
+ color: #333;
}
diff --git a/web/src/utils/traces/convertTraceData.ts b/web/src/utils/traces/convertTraceData.ts
index a554ab1dbe..3ab035859b 100644
--- a/web/src/utils/traces/convertTraceData.ts
+++ b/web/src/utils/traces/convertTraceData.ts
@@ -246,25 +246,36 @@ export const convertServiceGraphToTree = (
.map((edge: any) => buildTree(edge.to, new Set(visited), edge))
.filter((child: any) => child !== null);
- // Calculate node metrics - direction-aware based on tree position
+ // Simple: show only incoming requests from parent in this tree path
let totalRequests: number;
let failedRequests: number;
let errorRate: number;
if (incomingEdge) {
- // Child node: use the incoming edge's metrics (traffic via this specific edge)
+ // Non-root: show incoming from parent
totalRequests = incomingEdge.total_requests ?? 0;
failedRequests = incomingEdge.failed_requests ?? 0;
errorRate = incomingEdge.error_rate ?? 0;
} else {
- // Root node: sum of all outgoing edges (traffic flowing from this node)
+ // Root: sum of outgoing edges
totalRequests = outgoingEdges.reduce((sum: number, edge: any) => sum + (edge.total_requests ?? 0), 0);
failedRequests = outgoingEdges.reduce((sum: number, edge: any) => sum + (edge.failed_requests ?? 0), 0);
- // If no outgoing edges, fall back to node's own error_rate
- errorRate = totalRequests > 0 ? (failedRequests / totalRequests) * 100 : (node.error_rate ?? 0);
+
+ // If no edges, use node's own metrics
+ if (totalRequests === 0 && node.requests !== undefined) {
+ totalRequests = node.requests;
+ failedRequests = node.errors ?? 0;
+ }
+
+ errorRate = totalRequests > 0 ? (failedRequests / totalRequests) * 100 : 0;
+
+ // If still no data, try node.error_rate directly
+ if (errorRate === 0 && node.error_rate !== undefined) {
+ errorRate = node.error_rate;
+ }
}
- // Calculate connections count (incoming + outgoing edges)
+ // Calculate connections count
const incomingEdges = incomingEdgesMap.get(nodeId) || [];
const connectionCount = incomingEdges.length + outgoingEdges.length;