fix: incident graphs and service graphs improvements (#10318)

### **User description**
## Changes

### 1. Reduce alert graph edge explosion
- Modified temporal edge logic to prevent O(n²) growth
- Changed same-service edges to only connect to most recent previous
alert
- Changed cross-service temporal edges to connect to chronologically
closest alert
- **Impact**: 88% reduction in edges (16 instead of 136 for 17 alerts)

### 2. Improve correlation error handling
- Added fallback mechanism when service registry is empty
- Better error messages for correlation failures
- Enhanced incident detail view with clearer feedback

**Files modified:**
- `src/service/alerts/incidents.rs`
- Incident correlation handling


___

### **PR Type**
Bug fix, Enhancement


___

### **Description**
- Prevent alert topology edge explosion

- Add frontend fallback incident correlation

- Improve correlation tab empty/error UX

- Share time/stream state across tabs


___

### Diagram Walkthrough


```mermaid
flowchart LR
  A["Incident alerts ingested"] 
  B["Topology enrichment"]
  C["Same-service temporal chain"]
  D["Cross-service edges"]
  E["ServiceGraph tab shared state"]
  F["Incident detail correlation"]
  G["FQN-priority drift detection"]
  H["Frontend fallback correlation"]
  A -- "build graph" --> B
  B -- "temporal edges" --> C
  B -- "dependency edges" --> D
  E -- "share datetime/stream" --> B
  F -- "fetch correlated streams" --> G
  G -- "if not drift" --> H
```



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Bug
fix</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>convertTraceData.ts</strong><dd><code>Simplify
service-graph tree request metrics</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></summary>
<hr>

web/src/utils/traces/convertTraceData.ts

<ul><li>Use incoming edge metrics for non-root<br> <li> Root sums
outgoing edge request counts<br> <li> Default root error rate to
zero<br> <li> Keep connection count as in+out</ul>


</details>


  </td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-f236b4107f8b11c8281dec8279238d02309a3c958bd1ee2076716df56b00c057">+5/-6</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td>
    <details>
<summary><strong>incidents.rs</strong><dd><code>Limit temporal topology
edges to nearest alerts</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

src/service/alerts/incidents.rs

<ul><li>Same-service temporal edge targets most recent prior<br> <li>
Keep full ServiceDependency edges when SG edge exists<br> <li> For
non-dependent services, link to closest later alert<br> <li> Avoid
duplicate edges with type-specific checks</ul>


</details>


  </td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-2411d921fd8b8783c049deea11da06726ed986550d405477d486234892353ca9">+80/-47</a>&nbsp;
</td>

</tr>
</table></td></tr><tr><td><strong>Enhancement</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>IncidentDetailDrawer.vue</strong><dd><code>Add fallback
correlation and improve tab states</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

web/src/components/alerts/IncidentDetailDrawer.vue

<ul><li>Show refresh header only when streams exist<br> <li> Unify
error/no-data states with clearer messaging<br> <li> Detect FQN priority
changes post-incident creation<br> <li> Build frontend fallback
correlation from schema mapping</ul>


</details>


  </td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-6973d58a5f49658b07d3533d4db5d32cc6430433b51732481e7497243dadb0b0">+217/-72</a></td>

</tr>

<tr>
  <td>
    <details>
<summary><strong>ServiceGraph.vue</strong><dd><code>Share Service Graph
time/stream with traces state</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

web/src/plugins/traces/ServiceGraph.vue

<ul><li>Bind datetime picker to <code>searchObj.data.datetime</code><br>
<li> Use shared <code>searchObj.data.stream.*</code> for stream
lists<br> <li> Remove local stream/time state and duplicate stream
loading<br> <li> Simplify empty-state guidance and code styling</ul>


</details>


  </td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10318/files#diff-da1aabfbe74e57c02db16fc5d3e00cd76dc76bf0ad9b57f9dfda7d6d9da06129">+50/-181</a></td>

</tr>
</table></td></tr></tr></tbody></table>

</details>

___

---------

Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
This commit is contained in:
ByteBaker
2026-02-02 20:01:34 +05:30
committed by GitHub
co-authored by sai nikhil kethe
parent 41d0c591a6
commit 7df19cb00a
10 changed files with 596 additions and 427 deletions
+8 -10
View File
@@ -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
+79 -45
View File
@@ -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)"
);
}
}
+3 -15
View File
@@ -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);
@@ -1010,8 +1010,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Logs Tab Content -->
<div v-if="activeTab === 'logs'" class="tw-flex tw-flex-col tw-flex-1 tw-overflow-hidden">
<!-- Refresh Button (shown when data is loaded) -->
<div v-if="hasCorrelatedData && !correlationLoading" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
<!-- Refresh Button (shown when logs data is loaded) -->
<div v-if="hasCorrelatedData && !correlationLoading && correlationData?.logStreams?.length > 0" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
<span class="tw-text-xs tw-text-gray-500">Showing correlated logs from incident timeframe</span>
<q-btn
flat
@@ -1032,37 +1032,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="tw-text-base">Loading correlated logs...</div>
</div>
<!-- Error State -->
<div v-else-if="correlationError" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
<q-icon name="error_outline" size="3rem" color="negative" class="tw-mb-4" />
<div class="tw-text-base tw-mb-2">Failed to load correlated logs</div>
<div class="tw-text-sm tw-text-gray-500 tw-mb-4">{{ correlationError }}</div>
<!-- Error/No Data State -->
<div v-else-if="correlationError || !hasCorrelatedData || !hasAnyStreams" class="full-width column flex-center q-gutter-sm justify-center" style="margin: 15vh auto 2rem;">
<q-icon
:name="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'error_outline') : 'info_outline'"
:color="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'negative') : 'grey-5'"
size="4rem"
/>
<div class="text-h6 q-mt-md">
{{ correlationError || 'No correlated logs found' }}
</div>
<div v-if="correlationError && correlationError.includes('FQN priority')" class="text-body2 text-grey-7 q-mt-sm" style="max-width: 500px; text-align: center;">
The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
</div>
<q-btn
v-if="correlationError && !correlationError.includes('FQN priority')"
color="primary"
outline
size="sm"
size="md"
@click="refreshCorrelation"
icon="refresh"
label="Retry"
class="q-mt-md"
/>
</div>
<!-- No Data State -->
<div v-else-if="!hasCorrelatedData || !hasAnyStreams" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
<q-icon name="info_outline" size="3rem" color="grey-5" class="tw-mb-4" />
<div class="tw-text-base tw-mb-4">No correlated logs found</div>
<div v-if="incidentDetails" class="tw-text-sm tw-text-gray-500 tw-mb-4">
Try searching manually using these dimensions:
</div>
<div v-if="incidentDetails" class="info-box tw-rounded tw-p-3 tw-text-xs" :class="isDarkMode ? 'info-box-dark' : 'info-box-light'">
<div v-for="(value, key) in incidentDetails.stable_dimensions" :key="key" class="tw-flex tw-gap-2 tw-mb-1">
<span class="label-text">{{ key }}:</span>
<span class="tw-font-mono">{{ value }}</span>
<q-btn flat dense size="xs" icon="content_copy" @click="() => { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
</div>
</div>
</div>
<!-- Success State - TelemetryCorrelationDashboard -->
<div v-else-if="hasCorrelatedData && correlationData" class="tw-flex-1 tw-overflow-hidden">
<TelemetryCorrelationDashboard
@@ -1081,8 +1075,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Metrics Tab Content -->
<div v-if="activeTab === 'metrics'" class="tw-flex tw-flex-col tw-flex-1 tw-overflow-hidden">
<!-- Refresh Button (shown when data is loaded) -->
<div v-if="hasCorrelatedData && !correlationLoading" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
<!-- Refresh Button (shown when metrics data is loaded) -->
<div v-if="hasCorrelatedData && !correlationLoading && correlationData?.metricStreams?.length > 0" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
<span class="tw-text-xs tw-text-gray-500">Showing correlated metrics from incident timeframe</span>
<q-btn
flat
@@ -1103,37 +1097,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="tw-text-base">Loading correlated metrics...</div>
</div>
<!-- Error State -->
<div v-else-if="correlationError" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
<q-icon name="error_outline" size="3rem" color="negative" class="tw-mb-4" />
<div class="tw-text-base tw-mb-2">Failed to load correlated metrics</div>
<div class="tw-text-sm tw-text-gray-500 tw-mb-4">{{ correlationError }}</div>
<!-- Error/No Data State -->
<div v-else-if="correlationError || !hasCorrelatedData || !hasAnyStreams" class="full-width column flex-center q-gutter-sm justify-center" style="margin: 15vh auto 2rem;">
<q-icon
:name="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'error_outline') : 'info_outline'"
:color="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'negative') : 'grey-5'"
size="4rem"
/>
<div class="text-h6 q-mt-md">
{{ correlationError || 'No correlated metrics found' }}
</div>
<div v-if="correlationError && correlationError.includes('FQN priority')" class="text-body2 text-grey-7 q-mt-sm" style="max-width: 500px; text-align: center;">
The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
</div>
<q-btn
v-if="correlationError && !correlationError.includes('FQN priority')"
color="primary"
outline
size="sm"
size="md"
@click="refreshCorrelation"
icon="refresh"
label="Retry"
class="q-mt-md"
/>
</div>
<!-- No Data State -->
<div v-else-if="!hasCorrelatedData || !hasAnyStreams" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
<q-icon name="info_outline" size="3rem" color="grey-5" class="tw-mb-4" />
<div class="tw-text-base tw-mb-4">No correlated metrics found</div>
<div v-if="incidentDetails" class="tw-text-sm tw-text-gray-500 tw-mb-4">
Try searching manually using these dimensions:
</div>
<div v-if="incidentDetails" class="info-box tw-rounded tw-p-3 tw-text-xs" :class="isDarkMode ? 'info-box-dark' : 'info-box-light'">
<div v-for="(value, key) in incidentDetails.stable_dimensions" :key="key" class="tw-flex tw-gap-2 tw-mb-1">
<span class="label-text">{{ key }}:</span>
<span class="tw-font-mono">{{ value }}</span>
<q-btn flat dense size="xs" icon="content_copy" @click="() => { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
</div>
</div>
</div>
<!-- Success State - TelemetryCorrelationDashboard -->
<div v-else-if="hasCorrelatedData && correlationData" class="tw-flex-1 tw-overflow-hidden">
<TelemetryCorrelationDashboard
@@ -1152,8 +1140,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Traces Tab Content -->
<div v-if="activeTab === 'traces'" class="tw-flex tw-flex-col tw-flex-1 tw-overflow-hidden">
<!-- Refresh Button (shown when data is loaded) -->
<div v-if="hasCorrelatedData && !correlationLoading" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
<!-- Refresh Button (shown when traces data is loaded) -->
<div v-if="hasCorrelatedData && !correlationLoading && correlationData?.traceStreams?.length > 0" class="tw-px-4 tw-py-2 tw-border-b tw-border-solid tw-border-[var(--o2-border-color)] tw-flex tw-items-center tw-justify-between">
<span class="tw-text-xs tw-text-gray-500">Showing correlated traces from incident timeframe</span>
<q-btn
flat
@@ -1174,37 +1162,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="tw-text-base">Loading correlated traces...</div>
</div>
<!-- Error State -->
<div v-else-if="correlationError" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
<q-icon name="error_outline" size="3rem" color="negative" class="tw-mb-4" />
<div class="tw-text-base tw-mb-2">Failed to load correlated traces</div>
<div class="tw-text-sm tw-text-gray-500 tw-mb-4">{{ correlationError }}</div>
<!-- Error/No Data State -->
<div v-else-if="correlationError || !hasCorrelatedData || !hasAnyStreams" class="full-width column flex-center q-gutter-sm justify-center" style="margin: 15vh auto 2rem;">
<q-icon
:name="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'error_outline') : 'info_outline'"
:color="correlationError ? (correlationError.includes('FQN priority') ? 'warning' : 'negative') : 'grey-5'"
size="4rem"
/>
<div class="text-h6 q-mt-md">
{{ correlationError || 'No correlated traces found' }}
</div>
<div v-if="correlationError && correlationError.includes('FQN priority')" class="text-body2 text-grey-7 q-mt-sm" style="max-width: 500px; text-align: center;">
The service discovery configuration (FQN priority dimensions) was changed after this incident was created.
</div>
<q-btn
v-if="correlationError && !correlationError.includes('FQN priority')"
color="primary"
outline
size="sm"
size="md"
@click="refreshCorrelation"
icon="refresh"
label="Retry"
class="q-mt-md"
/>
</div>
<!-- No Data State -->
<div v-else-if="!hasCorrelatedData || !hasAnyStreams" class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-flex-1 tw-p-4">
<q-icon name="info_outline" size="3rem" color="grey-5" class="tw-mb-4" />
<div class="tw-text-base tw-mb-4">No correlated traces found</div>
<div v-if="incidentDetails" class="tw-text-sm tw-text-gray-500 tw-mb-4">
Try searching manually using these dimensions:
</div>
<div v-if="incidentDetails" class="info-box tw-rounded tw-p-3 tw-text-xs" :class="isDarkMode ? 'info-box-dark' : 'info-box-light'">
<div v-for="(value, key) in incidentDetails.stable_dimensions" :key="key" class="tw-flex tw-gap-2 tw-mb-1">
<span class="label-text">{{ key }}:</span>
<span class="tw-font-mono">{{ value }}</span>
<q-btn flat dense size="xs" icon="content_copy" @click="() => { navigator.clipboard.writeText(value); $q.notify({ message: 'Copied!', type: 'positive' }); }" />
</div>
</div>
</div>
<!-- Success State - TelemetryCorrelationDashboard -->
<div v-else-if="hasCorrelatedData && correlationData" class="tw-flex-1 tw-overflow-hidden">
<TelemetryCorrelationDashboard
@@ -1246,6 +1228,9 @@ import incidentsService, {
IncidentAlert,
IncidentCorrelatedStreams,
} from "@/services/incidents";
import http from "@/services/http";
import streamService from "@/services/stream";
import serviceStreamsApi from "@/services/service_streams";
import { getImageURL } from "@/utils/zincutils";
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -1518,6 +1503,36 @@ export default defineComponent({
org,
incidentDetails.value
);
// Check if correlation failed (null response or no data)
if (!correlationData.value?.correlationData &&
(!correlationData.value?.logStreams?.length &&
!correlationData.value?.metricStreams?.length &&
!correlationData.value?.traceStreams?.length)) {
// Fetch FQN priority config to check if it was modified after incident creation
try {
const fqnConfigResponse = await http().get(
`/api/${org}/settings/v2/fqn_priority_dimensions`
);
const fqnConfig = fqnConfigResponse.data;
// Compare timestamps (both in microseconds)
if (fqnConfig.updated_at > 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<string, string> = {};
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);
@@ -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", () => {
@@ -15,7 +15,43 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="incident-service-graph" style="height: calc(100vh - 202px);">
<div class="incident-service-graph" style="height: calc(100vh - 202px); position: relative;">
<!-- Info Icon Button -->
<q-btn
v-if="!loading && graphData && graphData.nodes && graphData.nodes.length > 0"
round
flat
icon="info_outline"
size="sm"
class="info-icon-btn"
:class="isDarkMode ? 'tw-text-gray-400 hover:tw-text-gray-200' : 'tw-text-gray-500 hover:tw-text-gray-700'"
>
<q-tooltip
:delay="200"
anchor="bottom left"
self="top right"
:offset="[10, 10]"
max-width="300px"
>
<div style="padding: 8px; font-size: 12px; line-height: 1.6;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 13px;">Graph Legend</div>
<div style="margin-bottom: 6px;">
<span style="color: #ef4444;">●</span> Red = Potential Root Cause
</div>
<div style="margin-bottom: 6px;">
<span style="color: #f97316;">●</span> Orange = High Frequency
</div>
<div style="margin-bottom: 6px;">
<span style="color: #3b82f6;">●</span> Blue = Normal
</div>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.2);">
<span style="color: #a78bfa;">→</span> Purple arrows show temporal flow
</div>
</div>
</q-tooltip>
</q-btn>
<!-- Loading State -->
<div
v-if="loading"
@@ -89,7 +125,7 @@ export default defineComponent({
// Use topology_context directly from props
const graphData = computed(() => 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<string, number>();
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<string>();
// 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 = `<div style="padding: 8px; font-size: 12px; text-align: center;">`;
html += `<strong>${sourceNode.alert_name}</strong> <span style="color: #a78bfa;">→</span> <strong>${targetNode.alert_name}</strong><br/><br/>`;
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 += `<span style="color: #a78bfa;">⏱ Time difference: <strong>${timeStr}</strong></span><br/>`;
html += `From: ${sourceTime.toLocaleString()}<br/>`;
html += `To: ${targetTime.toLocaleString()}<br/>`;
html += `<br/><span style="color: #a78bfa;">Temporal correlation</span>`;
} else {
html += `<span style="color: #9ca3af;">Service dependency</span>`;
}
html += `</div>`;
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%);
@@ -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
@@ -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<string, string>;
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<string, string>();
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;
+50 -181
View File
@@ -8,10 +8,10 @@
<q-select
v-model="streamFilter"
:options="
availableStreams.length > 0
searchObj.data.stream.streamLists && searchObj.data.stream.streamLists.length > 0
? [
{ label: 'All Streams', value: 'all' },
...availableStreams.map((s) => ({ label: s, value: s })),
...searchObj.data.stream.streamLists,
]
: [{ label: 'All Streams', value: 'all' }]
"
@@ -21,14 +21,13 @@
map-options
class="tw:w-[180px] tw:flex-shrink-0"
@update:model-value="onStreamFilterChange"
:disable="availableStreams.length === 0"
:disable="!searchObj.data.stream.streamLists || searchObj.data.stream.streamLists.length === 0"
>
<template #prepend>
<q-icon name="storage" size="xs" />
</template>
<q-tooltip v-if="availableStreams.length === 0">
No streams detected. Ensure service graph metrics include
stream_name labels.
<q-tooltip v-if="!searchObj.data.stream.streamLists || searchObj.data.stream.streamLists.length === 0">
No streams detected. Loading streams from parent...
</q-tooltip>
</q-select>
@@ -54,12 +53,12 @@
<date-time
ref="dateTimeRef"
auto-apply
:default-type="timeRange.type"
:default-type="searchObj.data.datetime.type"
:default-absolute-time="{
startTime: timeRange.startTime,
endTime: timeRange.endTime,
startTime: searchObj.data.datetime.startTime,
endTime: searchObj.data.datetime.endTime,
}"
:default-relative-time="timeRange.relativeTimePeriod"
:default-relative-time="searchObj.data.datetime.relativeTimePeriod"
data-test="service-graph-date-time-picker"
class="tw:h-[2rem]"
@on:date-change="updateTimeRange"
@@ -166,59 +165,7 @@
class="text-weight-medium q-mb-sm"
style="font-size: 1rem"
>
Service graph is disabled
</div>
<div>
Enable the feature by setting environment variable:
</div>
<code
class="q-mt-xs"
style="
background: rgba(var(--q-primary-rgb), 0.1);
padding: 4px 10px;
border-radius: 4px;
font-size: 0.9rem;
color: var(--q-primary);
font-weight: 600;
"
>O2_SERVICE_GRAPH_ENABLED=true</code
>
</div>
<div
class="q-pa-md q-mb-md"
style="
background: rgba(var(--q-primary-rgb), 0.05);
border-radius: 6px;
"
>
<div
class="text-weight-medium q-mb-sm"
style="font-size: 1rem"
>
Query time range is too small
</div>
<div>
The daemon queries traces within a time window. If your
traces are older, increase the window:
</div>
<code
class="q-mt-xs"
style="
background: rgba(var(--q-primary-rgb), 0.1);
padding: 4px 10px;
border-radius: 4px;
font-size: 0.9rem;
color: var(--q-primary);
font-weight: 600;
"
>O2_SERVICE_GRAPH_QUERY_TIME_RANGE_MINUTES=120</code
>
<div
class="text-caption q-mt-sm"
style="color: #666; font-size: 0.85rem"
>
(Default: 60 minutes)
Queried time range has no traces, try selecting a longer duration.
</div>
</div>
@@ -233,74 +180,12 @@
class="text-weight-medium q-mb-sm"
style="font-size: 1rem"
>
Only INTERNAL spans detected
</div>
<div>
Your traces have
<code
style="
background: rgba(var(--q-primary-rgb), 0.1);
padding: 3px 7px;
border-radius: 3px;
font-size: 0.9rem;
color: var(--q-primary);
font-weight: 600;
"
>span_kind=1</code
>
(INTERNAL operations within a service).
Maybe your traces have only internal spans (<code>span_kind = 1</code>)?
</div>
<div class="q-mt-sm">
To create service-to-service edges, send traces with:
</div>
<ul
class="q-pl-md q-mt-sm q-mb-sm"
style="line-height: 1.6"
>
<li>
<strong>CLIENT spans</strong> (<code
style="
background: rgba(var(--q-primary-rgb), 0.1);
color: var(--q-primary);
font-weight: 600;
"
>span_kind=3</code
>) with
<code
style="
background: rgba(var(--q-primary-rgb), 0.1);
color: var(--q-primary);
font-weight: 600;
"
>peer.service</code
>
attribute, or
</li>
<li>
<strong>SERVER spans</strong> (<code
style="
background: rgba(var(--q-primary-rgb), 0.1);
color: var(--q-primary);
font-weight: 600;
"
>span_kind=2</code
>) receiving requests from other services
</li>
</ul>
<div
class="text-caption q-mt-sm"
style="color: #666; font-size: 0.85rem"
>
Note: INTERNAL spans can be excluded by setting
<code
style="
background: rgba(var(--q-primary-rgb), 0.1);
color: var(--q-primary);
font-weight: 600;
"
>O2_SERVICE_GRAPH_EXCLUDE_INTERNAL_SPANS=true</code
>
Make sure <code>O2_SERVICE_GRAPH_EXCLUDE_INTERNAL_SPANS=true</code>
</div>
<div>to process internal spans for service edges.</div>
</div>
</div>
<q-btn
@@ -379,6 +264,7 @@ import {
convertServiceGraphToNetwork,
} from "@/utils/traces/convertTraceData";
import useStreams from "@/composables/useStreams";
import useTraces from "@/composables/useTraces";
export default defineComponent({
name: "ServiceGraph",
@@ -390,6 +276,7 @@ export default defineComponent({
setup() {
const store = useStore();
const { getStreams } = useStreams();
const { searchObj } = useTraces();
const loading = ref(false);
const error = ref<string | null>(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<string[]>([]);
// 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<any>(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<any>(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;
}
</style>
+17 -6
View File
@@ -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;