feat: add service graph as enterprise-only feature

Service graph automatically discovers service topology from distributed traces.

OSS includes:
- API routing endpoints (gated with enterprise feature flag)
- UI components (functional only with enterprise backend)
- Configuration options

Enterprise includes:
- 256-shard concurrent edge store with LRU eviction
- Span matching and processing logic
- Prometheus metrics and background workers
This commit is contained in:
ByteBaker
2025-10-30 17:20:21 +05:30
committed by sktrpathi
parent 848c604653
commit e4e7274ec8
13 changed files with 1278 additions and 7 deletions
+1
View File
@@ -35,3 +35,4 @@ tests/ui-testing/playwright-tests/utils/auth/user.json
/tests/ui-testing/utils/test-mapping/archived-utils
/tests/ui-testing/playwright-results
/tests/ui-testing/testDinoPOC/MD Files
*.bak
Generated
+2 -2
View File
@@ -9491,9 +9491,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.13.2"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
+33
View File
@@ -496,6 +496,7 @@ pub struct Config {
pub health_check: HealthCheck,
pub encryption: Encryption,
pub enrichment_table: EnrichmentTable,
pub service_graph: ServiceGraph,
}
#[derive(EnvConfig, Default)]
@@ -2115,6 +2116,38 @@ pub struct EnrichmentTable {
pub merge_interval: u64,
}
/// Service Graph Configuration
///
/// Note: Worker count is fixed at 256 (one per shard) for optimal concurrency.
/// No async channels are used in the current implementation.
#[derive(EnvConfig, Default)]
pub struct ServiceGraph {
#[env_config(
name = "ZO_SGRAPH_ENABLED",
default = false,
help = "Enable service graph feature"
)]
pub enabled: bool,
#[env_config(
name = "ZO_SGRAPH_WAIT_DURATION_MS",
default = 10000,
help = "Wait duration for span pairing in milliseconds"
)]
pub wait_duration_ms: u64,
#[env_config(
name = "ZO_SGRAPH_MAX_ITEMS_PER_SHARD",
default = 100000,
help = "Maximum items per shard in edge store (256 shards total)"
)]
pub max_items_per_shard: usize,
#[env_config(
name = "ZO_SGRAPH_CLEANUP_INTERVAL_MS",
default = 2000,
help = "Cleanup interval for expired edges in milliseconds"
)]
pub cleanup_interval_ms: u64,
}
pub fn init() -> Config {
if let Err(e) = load_config() {
log::error!("Failed to load config {e}");
+3
View File
@@ -26,6 +26,9 @@ use hashbrown::HashMap;
use serde::Serialize;
use tracing::{Instrument, Span};
// Re-export service graph API handlers
#[cfg(feature = "enterprise")]
pub use crate::service::traces::service_graph::{get_service_graph_metrics, get_store_stats};
use crate::{
common::{
meta::{self, http::HttpResponse as MetaHttpResponse},
+3 -1
View File
@@ -612,7 +612,9 @@ pub fn get_service_routes(svc: &mut web::ServiceConfig) {
.service(re_pattern::delete)
.service(re_pattern::test)
.service(domain_management::get_domain_management_config)
.service(domain_management::set_domain_management_config);
.service(domain_management::set_domain_management_config)
.service(traces::get_service_graph_metrics)
.service(traces::get_store_stats);
#[cfg(feature = "cloud")]
let service = service
+7
View File
@@ -309,6 +309,13 @@ async fn main() -> Result<(), anyhow::Error> {
panic!("job init failed: {e}");
}
// init service graph workers
#[cfg(feature = "enterprise")]
if cfg.service_graph.enabled {
log::info!("Initializing service graph background workers");
openobserve::service::traces::service_graph::init_background_workers();
}
// Register job runtime for metrics collection
if let Ok(handle) = tokio::runtime::Handle::try_current() {
openobserve::service::runtime_metrics::register_runtime("job".to_string(), handle);
+50 -4
View File
@@ -41,6 +41,9 @@ use opentelemetry_proto::tonic::{
use prost::Message;
use serde_json::Map;
#[cfg(feature = "enterprise")]
pub mod service_graph;
#[cfg(feature = "cloud")]
use crate::service::stream::get_stream;
use crate::{
@@ -343,22 +346,65 @@ pub async fn handle_otlp_request(
}
let local_val = Span {
trace_id: trace_id.clone(),
span_id,
span_id: span_id.clone(),
span_kind: span.kind.to_string(),
span_status: get_span_status(span.status),
span_status: get_span_status(span.status.clone()),
operation_name: span.name.clone(),
start_time,
end_time,
duration: (end_time - start_time) / 1000, // microseconds
reference: span_ref,
reference: span_ref.clone(),
service_name: service_name.clone(),
attributes: span_att_map,
attributes: span_att_map.clone(),
service: service_att_map.clone(),
flags: 1, // TODO add appropriate value
events: json::to_string(&events).unwrap(),
links: json::to_string(&links).unwrap(),
};
// Process span for service graph if enabled
#[cfg(feature = "enterprise")]
if cfg.service_graph.enabled {
// Wrap in catch_unwind to prevent panics from crashing trace ingestion
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
log::trace!(
"[ServiceGraph] Processing span: trace_id={}, span_id={}",
trace_id,
span_id
);
let parent_span_id_opt =
span_ref.get(PARENT_SPAN_ID).map(|s| Arc::from(s.as_str()));
// Convert HashMap to json::Map
let mut attrs_map = json::Map::new();
for (k, v) in &span_att_map {
attrs_map.insert(k.clone(), v.clone());
}
let graph_span = service_graph::span_to_graph_span(
Arc::from(trace_id.as_str()),
Arc::from(span_id.as_str()),
parent_span_id_opt,
Arc::from(service_name.as_str()),
&local_val.span_kind,
start_time,
end_time,
&local_val.span_status,
&attrs_map,
);
service_graph::process_span(org_id, graph_span);
}));
if let Err(e) = result {
log::error!(
"[ServiceGraph] Panic caught during span processing: {:?}",
e
);
// Increment error metric
service_graph::SERVICE_GRAPH_DROPPED_SPANS
.with_label_values(&[org_id])
.inc();
}
}
let mut value: json::Value = json::to_value(local_val).unwrap();
// add timestamp
value.as_object_mut().unwrap().insert(
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use actix_web::{HttpResponse, web};
use config::utils::json;
use prometheus::{Encoder, TextEncoder};
use serde::Deserialize;
/// Query parameters for service graph API
#[derive(Debug, Deserialize)]
pub struct ServiceGraphQuery {
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub filter: Option<String>,
}
/// Handler for service graph metrics endpoint (Prometheus format)
///
/// Returns Prometheus metrics filtered by org_id.
/// Metrics are filtered by the `org_id` label to ensure multi-tenant isolation.
#[actix_web::get("/{org_id}/traces/service_graph/metrics")]
pub async fn get_service_graph_metrics(
org_id: web::Path<String>,
) -> Result<HttpResponse, actix_web::Error> {
let metric_families = prometheus::gather();
let encoder = TextEncoder::new();
let mut buffer = vec![];
// Filter metrics to only include those matching the requested org_id
let filtered_families: Vec<_> = metric_families
.into_iter()
.filter_map(|mut family| {
// Filter out metrics that don't belong to this org
let filtered_metrics: Vec<_> = family
.take_metric()
.into_iter()
.filter(|metric| {
// Check if metric has org_id label matching the requested org
metric
.get_label()
.iter()
.any(|label| label.name() == "org_id" && label.value() == org_id.as_str())
})
.collect();
if filtered_metrics.is_empty() {
None
} else {
family.set_metric(filtered_metrics);
Some(family)
}
})
.collect();
encoder.encode(&filtered_families, &mut buffer).unwrap();
Ok(HttpResponse::Ok()
.content_type("text/plain; version=0.0.4")
.body(buffer))
}
/// Get service graph store statistics
///
/// Returns statistics filtered by org_id to ensure multi-tenant isolation.
#[actix_web::get("/{org_id}/traces/service_graph/stats")]
pub async fn get_store_stats(org_id: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
let store = o2_enterprise::enterprise::service_graph::get_store();
let org_sizes = store.get_org_sizes();
let org_capacities = store.get_org_capacity_utilization();
// Get stats for the requested org only
let org_store_size = org_sizes.get(org_id.as_str()).copied().unwrap_or(0);
let org_capacity_util = org_capacities.get(org_id.as_str()).copied().unwrap_or(0.0);
let stats = json::json!({
"enabled": config::get_config().service_graph.enabled,
"store_size": org_store_size,
"capacity_utilization_percent": org_capacity_util,
"shard_count": store.shard_count(),
"config": {
"max_items_per_shard": store.config().max_items_per_shard,
"total_capacity": store.config().max_items_per_shard * store.shard_count(),
"wait_duration_ms": store.config().wait_duration.as_millis(),
}
});
Ok(HttpResponse::Ok().json(stats))
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Service Graph Module
//!
//! This module provides API endpoints for the service graph feature.
//! The core logic is implemented in the enterprise repository.
#[cfg(feature = "enterprise")]
pub mod api;
// Re-export API handlers for routing
#[cfg(feature = "enterprise")]
pub use api::{get_service_graph_metrics, get_store_stats};
// Re-export enterprise types and functions for internal use
#[cfg(feature = "enterprise")]
pub use o2_enterprise::enterprise::service_graph::{
ConnectionType, SERVICE_GRAPH_DROPPED_SPANS, SpanForGraph, SpanKind, init_background_workers,
process_span, shutdown_workers, span_to_graph_span,
};
+13
View File
@@ -30,6 +30,7 @@ const AppMetrics = () => import("@/plugins/metrics/Index.vue");
const AppTraces = () => import("@/plugins/traces/Index.vue");
const TraceDetails = () => import("@/plugins/traces/TraceDetails.vue");
const ServiceGraph = () => import("@/plugins/traces/ServiceGraph.vue");
const ViewDashboard = () => import("@/views/Dashboards/ViewDashboard.vue");
const AddPanel = () => import("@/views/Dashboards/addPanel/AddPanel.vue");
@@ -163,6 +164,18 @@ const useRoutes = () => {
routeGuard(to, from, next);
},
},
{
path: "service-graph",
name: "serviceGraph",
component: ServiceGraph,
meta: {
keepAlive: true,
title: "Service Graph",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
},
{
name: "streamExplorer",
path: "streams/stream-explore",
+7
View File
@@ -631,6 +631,7 @@ import {
outlinedDescription,
outlinedCode,
outlinedDevices,
outlinedBubbleChart,
} from "@quasar/extras/material-icons-outlined";
import SlackIcon from "@/components/icons/SlackIcon.vue";
import ManagementIcon from "@/components/icons/ManagementIcon.vue";
@@ -806,6 +807,12 @@ export default defineComponent({
link: "/traces",
name: "traces",
},
{
title: "Service Graph",
icon: outlinedBubbleChart,
link: "/service-graph",
name: "serviceGraph",
},
{
title: t("menu.rum"),
icon: outlinedDevices,
+993
View File
@@ -0,0 +1,993 @@
<template>
<div class="service-graph-container">
<q-card flat class="full-height">
<q-card-section class="q-pa-md">
<div class="row items-center justify-between q-mb-md">
<div class="col">
<div class="text-h5 text-bold">Service Graph</div>
<div class="text-subtitle2 text-grey-7">
Visualize service dependencies and communication patterns from distributed traces
</div>
</div>
<div class="col-auto row q-gutter-sm">
<q-btn
outline
dense
color="primary"
icon="refresh"
label="Refresh"
@click="loadServiceGraph"
:loading="loading"
/>
<q-btn-dropdown
outline
dense
color="primary"
icon="tune"
label="Layout"
>
<q-list>
<q-item clickable v-close-popup @click="setLayout('hierarchical')">
<q-item-section>
<q-item-label>Hierarchical</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="setLayout('force')">
<q-item-section>
<q-item-label>Force-Directed</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="setLayout('circular')">
<q-item-section>
<q-item-label>Circular</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
outline
dense
color="primary"
icon="settings"
@click="showSettings = true"
/>
</div>
</div>
<!-- Enhanced Stats Bar -->
<div class="row q-col-gutter-md q-mb-md" v-if="stats">
<div class="col-12 col-md-2">
<q-card flat bordered class="stat-card">
<q-card-section class="q-pa-md">
<div class="row items-center">
<q-icon name="hub" size="32px" color="primary" class="q-mr-sm" />
<div>
<div class="text-caption text-grey-7">Services</div>
<div class="text-h5 text-bold">{{ stats.services || 0 }}</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-2">
<q-card flat bordered class="stat-card">
<q-card-section class="q-pa-md">
<div class="row items-center">
<q-icon name="share" size="32px" color="blue" class="q-mr-sm" />
<div>
<div class="text-caption text-grey-7">Connections</div>
<div class="text-h5 text-bold">{{ stats.connections || 0 }}</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-3">
<q-card flat bordered class="stat-card">
<q-card-section class="q-pa-md">
<div class="row items-center">
<q-icon name="speed" size="32px" color="orange" class="q-mr-sm" />
<div>
<div class="text-caption text-grey-7">Total Requests</div>
<div class="text-h5 text-bold">{{ formatNumber(stats.totalRequests || 0) }}</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-3">
<q-card flat bordered class="stat-card">
<q-card-section class="q-pa-md">
<div class="row items-center">
<q-icon name="error_outline" size="32px" :color="stats.errorRate > 5 ? 'negative' : 'grey-5'" class="q-mr-sm" />
<div>
<div class="text-caption text-grey-7">Error Rate</div>
<div class="text-h5 text-bold" :class="stats.errorRate > 5 ? 'text-negative' : ''">
{{ stats.errorRate?.toFixed(2) || 0 }}%
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-2">
<q-card flat bordered class="stat-card">
<q-card-section class="q-pa-md">
<div class="row items-center">
<q-icon name="check_circle" size="32px" :color="storeStats?.enabled ? 'positive' : 'grey'" class="q-mr-sm" />
<div>
<div class="text-caption text-grey-7">Status</div>
<div class="text-body1 text-bold">
{{ storeStats?.enabled ? 'Active' : 'Inactive' }}
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Filters -->
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-12 col-md-4">
<q-input
v-model="searchFilter"
dense
outlined
placeholder="Search services..."
@update:model-value="applyFilters"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
<template v-slot:append v-if="searchFilter">
<q-icon name="close" @click="searchFilter = ''; applyFilters()" class="cursor-pointer" />
</template>
</q-input>
</div>
<div class="col-12 col-md-8">
<q-btn-toggle
v-model="connectionTypeFilter"
toggle-color="primary"
size="sm"
no-caps
dense
@update:model-value="applyFilters"
:options="[
{label: 'All', value: 'all'},
{label: 'Standard', value: 'standard'},
{label: 'Database', value: 'database'},
{label: 'Messaging', value: 'messaging'},
{label: 'Virtual', value: 'virtual'},
]"
/>
</div>
</div>
<!-- Graph Visualization -->
<q-card flat bordered class="graph-card">
<q-card-section class="q-pa-none" style="height: 100%;">
<div
ref="graphContainer"
class="graph-container"
:style="{ height: graphHeight + 'px', minHeight: graphHeight + 'px' }"
>
<div v-if="loading" class="flex flex-center full-height">
<div class="text-center">
<q-spinner-grid color="primary" size="4em" />
<div class="text-subtitle1 q-mt-md text-grey-7">Loading service graph...</div>
</div>
</div>
<div
v-else-if="error"
class="flex flex-center full-height text-center q-pa-md"
>
<div>
<q-icon name="error_outline" size="4em" color="negative" />
<div class="text-h6 q-mt-md">{{ error }}</div>
<q-btn
outline
color="primary"
label="Retry"
@click="loadServiceGraph"
class="q-mt-md"
/>
</div>
</div>
<div
v-else-if="!graphData.nodes.length"
class="flex flex-center full-height text-center q-pa-md"
>
<div>
<q-icon name="hub" size="5em" color="grey-4" />
<div class="text-h6 q-mt-md text-grey-7">No Service Graph Data</div>
<div class="text-body2 text-grey-6 q-mt-sm" style="max-width: 500px">
Send distributed traces with client and server spans to see the service graph.
Make sure <code>ZO_SGRAPH_ENABLED=true</code> is set.
</div>
<q-btn
outline
color="primary"
label="Refresh"
icon="refresh"
@click="loadServiceGraph"
class="q-mt-lg"
/>
</div>
</div>
<div v-else style="position: relative; width: 100%; height: 100%;">
<div id="graph-network"></div>
<!-- Node Details Panel (shown on node click) -->
<q-card
v-if="selectedNode"
class="node-details-card"
flat
bordered
>
<q-card-section class="q-pa-md">
<div class="row items-center justify-between q-mb-sm">
<div class="text-h6">{{ selectedNode.label }}</div>
<q-btn
flat
dense
round
icon="close"
size="sm"
@click="selectedNode = null"
/>
</div>
<q-separator class="q-mb-sm" />
<div class="text-caption text-grey-7 q-mb-xs">Connections</div>
<div class="q-mb-sm">
<div><strong>Incoming:</strong> {{ getIncomingConnections(selectedNode.id).length }}</div>
<div><strong>Outgoing:</strong> {{ getOutgoingConnections(selectedNode.id).length }}</div>
</div>
<div class="text-caption text-grey-7 q-mb-xs">Traffic</div>
<div>
<div><strong>Requests:</strong> {{ formatNumber(getNodeRequests(selectedNode.id)) }}</div>
<div><strong>Errors:</strong> {{ getNodeErrors(selectedNode.id) }}</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
</q-card-section>
</q-card>
<!-- Enhanced Legend -->
<div class="row items-center q-mt-md">
<div class="col-auto text-subtitle2 text-grey-7 q-mr-md">
Connection Types:
</div>
<div class="col">
<div class="row q-col-gutter-sm">
<div class="col-auto">
<q-chip dense square color="blue" text-color="white" icon="sync_alt">
Standard
</q-chip>
</div>
<div class="col-auto">
<q-chip dense square color="purple" text-color="white" icon="message">
Messaging
</q-chip>
</div>
<div class="col-auto">
<q-chip dense square color="orange" text-color="white" icon="storage">
Database
</q-chip>
</div>
<div class="col-auto">
<q-chip dense square color="grey" text-color="white" icon="cloud">
Virtual/External
</q-chip>
</div>
</div>
</div>
<div class="col-auto text-caption text-grey-6">
Last updated: {{ lastUpdated }}
</div>
</div>
</q-card-section>
</q-card>
<!-- Enhanced Settings Dialog -->
<q-dialog v-model="showSettings">
<q-card style="min-width: 450px">
<q-card-section>
<div class="text-h6">Service Graph Settings</div>
</q-card-section>
<q-separator />
<q-card-section>
<div class="q-gutter-md">
<q-input
v-model.number="graphHeight"
type="number"
label="Graph Height (px)"
:min="400"
:max="1200"
outlined
dense
/>
<q-toggle
v-model="autoRefresh"
label="Auto-refresh (every 30 seconds)"
@update:model-value="toggleAutoRefresh"
/>
<q-toggle
v-model="showLabels"
label="Show edge labels"
@update:model-value="renderGraph"
/>
<q-toggle
v-model="enablePhysics"
label="Enable physics simulation"
@update:model-value="updatePhysics"
/>
<div class="text-caption text-grey-7 q-mt-md">
Store Size: {{ storeStats?.store_size || 0 }} edges
<q-tooltip>Number of pending span pairs waiting to be matched</q-tooltip>
</div>
</div>
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn flat label="Reset" color="grey" @click="resetSettings" />
<q-btn flat label="Close" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, onBeforeUnmount } from "vue";
import { useStore } from "vuex";
import { useQuasar } from "quasar";
import serviceGraphService from "@/services/service_graph";
export default defineComponent({
name: "ServiceGraph",
setup() {
const store = useStore();
const $q = useQuasar();
const loading = ref(false);
const error = ref<string | null>(null);
const graphContainer = ref<HTMLElement | null>(null);
const showSettings = ref(false);
const graphHeight = ref(650);
const autoRefresh = ref(true);
const showLabels = ref(true);
const enablePhysics = ref(false); // Disabled by default for stable graph
const lastUpdated = ref("");
const layoutType = ref("force");
const searchFilter = ref("");
const connectionTypeFilter = ref("all");
const selectedNode = ref<any>(null);
const graphData = ref<any>({
nodes: [],
edges: [],
});
const filteredGraphData = ref<any>({
nodes: [],
edges: [],
});
const stats = ref<any>(null);
const storeStats = ref<any>(null);
let networkInstance: any = null;
let refreshInterval: any = null;
const loadServiceGraph = async () => {
loading.value = true;
error.value = null;
try {
const orgId = store.state.selectedOrganization.identifier;
if (!orgId) {
throw new Error("No organization selected");
}
// Load store stats with timeout
const statsResponse = await Promise.race([
serviceGraphService.getStats(orgId),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Request timeout")), 30000)
)
]);
storeStats.value = statsResponse.data;
if (!storeStats.value?.enabled) {
error.value = "Service Graph is not enabled. Set ZO_SGRAPH_ENABLED=true";
loading.value = false;
return;
}
// Load metrics and parse
const metricsResponse = await serviceGraphService.getMetrics(orgId);
const parsedData = parsePrometheusMetrics(metricsResponse.data);
graphData.value = parsedData;
// Calculate stats
const totalRequests = parsedData.edges.reduce((sum: number, e: any) => sum + e.total_requests, 0);
const totalErrors = parsedData.edges.reduce((sum: number, e: any) => sum + e.failed_requests, 0);
stats.value = {
services: parsedData.nodes.length,
connections: parsedData.edges.length,
totalRequests,
totalErrors,
errorRate: totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0,
};
lastUpdated.value = new Date().toLocaleTimeString();
// Apply filters and render
applyFilters();
} catch (err: any) {
console.error("Failed to load service graph:", err);
// Provide detailed error messages based on error type
if (err.message === "Request timeout") {
error.value = "Request timed out. The service graph may be processing large amounts of data. Please try again.";
} else if (err.response?.status === 404) {
error.value = "Service Graph API endpoint not found. Ensure you're running a version that supports Service Graph.";
} else if (err.response?.status === 403) {
error.value = "Access denied. You may not have permission to view the service graph for this organization.";
} else if (err.response?.status === 500) {
error.value = "Server error occurred. Check server logs for details.";
} else if (err.message === "Network Error" || !navigator.onLine) {
error.value = "Network error. Please check your internet connection.";
} else {
error.value = err.response?.data?.message || err.message || "Failed to load service graph data. Please check server logs.";
}
} finally {
loading.value = false;
}
};
const parsePrometheusMetrics = (metricsText: string) => {
const nodes = new Map<string, any>();
const edges: any[] = [];
const lines = metricsText.split("\n");
for (const line of lines) {
if (line.startsWith("#") || !line.trim()) continue;
// Parse metric line: metric_name{labels} value
const match = line.match(/^(\w+)\{([^}]+)\}\s+([\d.eE+-]+)/);
if (!match) continue;
const [, metricName, labelsStr, value] = match;
const labels: any = {};
// Parse labels
const labelMatches = labelsStr.matchAll(/(\w+)="([^"]+)"/g);
for (const [, key, val] of labelMatches) {
labels[key] = val;
}
if (!labels.client || !labels.server) continue;
// Add nodes
if (!nodes.has(labels.client)) {
nodes.set(labels.client, {
id: labels.client,
label: labels.client,
is_virtual: labels.client === "unknown",
});
}
if (!nodes.has(labels.server)) {
nodes.set(labels.server, {
id: labels.server,
label: labels.server,
is_virtual: labels.server.includes("unknown"),
});
}
// Add edge data
if (metricName === "traces_service_graph_request_total") {
const edgeId = `${labels.client}->${labels.server}`;
let edge = edges.find((e) => e.id === edgeId);
if (!edge) {
edge = {
id: edgeId,
from: labels.client,
to: labels.server,
connection_type: labels.connection_type || "standard",
total_requests: 0,
failed_requests: 0,
};
edges.push(edge);
}
edge.total_requests = parseFloat(value);
}
if (metricName === "traces_service_graph_request_failed_total") {
const edgeId = `${labels.client}->${labels.server}`;
let edge = edges.find((e) => e.id === edgeId);
// Create edge if it doesn't exist yet (failed_total may come before request_total)
if (!edge) {
edge = {
id: edgeId,
from: labels.client,
to: labels.server,
connection_type: labels.connection_type || "standard",
total_requests: 0,
failed_requests: 0,
};
edges.push(edge);
}
edge.failed_requests = parseFloat(value);
}
}
return {
nodes: Array.from(nodes.values()),
edges,
};
};
const applyFilters = () => {
let nodes = [...graphData.value.nodes];
let edges = [...graphData.value.edges];
// Filter by search
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase();
const matchingNodeIds = new Set(
nodes.filter(n => n.label.toLowerCase().includes(search)).map(n => n.id)
);
edges = edges.filter(e =>
matchingNodeIds.has(e.from) || matchingNodeIds.has(e.to)
);
const usedNodeIds = new Set([...edges.map(e => e.from), ...edges.map(e => e.to)]);
nodes = nodes.filter(n => usedNodeIds.has(n.id));
}
// Filter by connection type
if (connectionTypeFilter.value !== "all") {
edges = edges.filter(e => e.connection_type === connectionTypeFilter.value);
const usedNodeIds = new Set([...edges.map(e => e.from), ...edges.map(e => e.to)]);
nodes = nodes.filter(n => usedNodeIds.has(n.id));
}
filteredGraphData.value = { nodes, edges };
if (nodes.length > 0) {
setTimeout(() => renderGraph(), 100);
}
};
const setLayout = (type: string) => {
layoutType.value = type;
renderGraph();
};
const renderGraph = () => {
if (!graphContainer.value || !window.vis) {
console.warn("Graph container or vis.js not available");
return;
}
const container = document.getElementById("graph-network");
if (!container) return;
// Calculate error rates for each node
const nodeErrorRates = new Map<string, number>();
filteredGraphData.value.edges.forEach((edge: any) => {
const calcRate = (nodeId: string) => {
const current = nodeErrorRates.get(nodeId) || { total: 0, errors: 0 };
nodeErrorRates.set(nodeId, {
total: current.total + edge.total_requests,
errors: current.errors + edge.failed_requests,
});
};
calcRate(edge.from);
calcRate(edge.to);
});
// Prepare nodes for vis.js with Tempo-style arc borders
const nodes = filteredGraphData.value.nodes.map((node: any) => {
const errorData = nodeErrorRates.get(node.id);
const errorRate = errorData && errorData.total > 0
? (errorData.errors / errorData.total) * 100
: 0;
// Calculate border color based on error rate
let borderColor = "#4CAF50"; // Green for no/low errors
if (errorRate > 0 && errorRate <= 1) {
borderColor = "#8BC34A"; // Light green
} else if (errorRate > 1 && errorRate <= 5) {
borderColor = "#FFC107"; // Yellow
} else if (errorRate > 5 && errorRate <= 10) {
borderColor = "#FF9800"; // Orange
} else if (errorRate > 10) {
borderColor = "#F44336"; // Red
}
return {
id: node.id,
label: node.label,
title: `${node.label}\nRequests: ${formatNumber(errorData?.total || 0)}\nErrors: ${errorData?.errors || 0}\nError Rate: ${errorRate.toFixed(2)}%`,
color: {
background: node.is_virtual ? "#ECEFF1" : "#E3F2FD",
border: borderColor,
highlight: {
background: node.is_virtual ? "#CFD8DC" : "#BBDEFB",
border: borderColor,
},
},
shape: "circle",
size: 40,
margin: 15,
font: {
size: 13,
color: "#263238",
face: "Arial",
bold: true,
},
borderWidth: 4,
borderWidthSelected: 6,
shadow: {
enabled: true,
color: "rgba(0,0,0,0.2)",
size: 8,
x: 2,
y: 2,
},
};
});
// Prepare edges for vis.js
const edges = filteredGraphData.value.edges.map((edge: any) => {
const errorRate =
edge.total_requests > 0
? (edge.failed_requests / edge.total_requests) * 100
: 0;
const color = getConnectionTypeColor(edge.connection_type);
const width = Math.max(2, Math.min(10, Math.log10(edge.total_requests + 1) * 2));
const hasErrors = errorRate > 0;
const label = showLabels.value
? `${formatNumber(edge.total_requests)}${hasErrors ? '\n' + errorRate.toFixed(1) + '% err' : ''}`
: '';
return {
from: edge.from,
to: edge.to,
label,
arrows: { to: { enabled: true, scaleFactor: 0.8 } },
color: {
color,
highlight: color,
opacity: errorRate > 5 ? 0.5 : 1.0,
},
width,
font: { size: 11, align: "middle", background: "white" },
smooth: { type: "curvedCW", roundness: 0.2 },
dashes: errorRate > 10,
};
});
const data = { nodes, edges };
let layoutOptions: any = {};
if (layoutType.value === "hierarchical") {
layoutOptions = {
hierarchical: {
enabled: true,
direction: "UD",
sortMethod: "directed",
levelSeparation: 150,
nodeSpacing: 150,
},
};
} else if (layoutType.value === "circular") {
layoutOptions = {
hierarchical: false,
};
} else {
layoutOptions = {
improvedLayout: true,
hierarchical: { enabled: false },
};
}
const options = {
layout: layoutOptions,
physics: {
enabled: enablePhysics.value,
stabilization: {
enabled: true,
iterations: 400,
updateInterval: 20,
},
barnesHut: {
gravitationalConstant: -15000, // Much stronger repulsion for sparse layout
centralGravity: 0.1, // Less central pull
springLength: 400, // Longer springs = more spacing
springConstant: 0.01, // Weaker springs
damping: 0.7,
avoidOverlap: 1,
},
solver: 'barnesHut',
minVelocity: 0.75,
},
interaction: {
hover: true,
tooltipDelay: 200,
navigationButtons: true,
keyboard: true,
zoomView: true,
dragView: true,
},
nodes: {
borderWidth: 5,
borderWidthSelected: 7,
},
edges: {
smooth: {
enabled: true,
type: "curvedCW",
roundness: 0.2,
},
},
configure: {
enabled: false,
},
};
if (networkInstance) {
networkInstance.destroy();
}
networkInstance = new (window as any).vis.Network(container, data, options);
// Stabilize then disable physics for static display
if (!enablePhysics.value) {
networkInstance.once("stabilizationIterationsDone", () => {
networkInstance.setOptions({ physics: false });
});
}
// Add click handler
networkInstance.on("click", (params: any) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
const node = filteredGraphData.value.nodes.find((n: any) => n.id === nodeId);
selectedNode.value = node;
} else {
selectedNode.value = null;
}
});
// Circular layout implementation
if (layoutType.value === "circular") {
const positions: any = {};
const nodeCount = nodes.length;
const radius = Math.max(250, nodeCount * 30);
nodes.forEach((node: any, i: number) => {
const angle = (i / nodeCount) * 2 * Math.PI;
positions[node.id] = {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
};
});
networkInstance.setOptions({ physics: { enabled: false } });
setTimeout(() => {
networkInstance.setPositions(positions);
networkInstance.fit();
}, 100);
}
};
const getConnectionTypeColor = (type: string) => {
switch (type) {
case "messaging":
return "#9C27B0"; // Purple
case "database":
return "#FF9800"; // Orange
case "virtual":
return "#9E9E9E"; // Grey
default:
return "#2196F3"; // Blue
}
};
const getIncomingConnections = (nodeId: string) => {
return filteredGraphData.value.edges.filter((e: any) => e.to === nodeId);
};
const getOutgoingConnections = (nodeId: string) => {
return filteredGraphData.value.edges.filter((e: any) => e.from === nodeId);
};
const getNodeRequests = (nodeId: string) => {
const incoming = getIncomingConnections(nodeId);
const outgoing = getOutgoingConnections(nodeId);
return [...incoming, ...outgoing].reduce((sum, e) => sum + e.total_requests, 0);
};
const getNodeErrors = (nodeId: string) => {
const incoming = getIncomingConnections(nodeId);
const outgoing = getOutgoingConnections(nodeId);
return [...incoming, ...outgoing].reduce((sum, e) => sum + e.failed_requests, 0);
};
const formatNumber = (num: number) => {
if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
if (num >= 1000) return (num / 1000).toFixed(1) + "K";
return num.toString();
};
const toggleAutoRefresh = (enabled: boolean) => {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
if (enabled) {
refreshInterval = setInterval(loadServiceGraph, 30000);
}
};
const updatePhysics = () => {
if (networkInstance) {
networkInstance.setOptions({
physics: { enabled: enablePhysics.value },
});
}
};
const resetSettings = () => {
graphHeight.value = 650;
autoRefresh.value = true;
showLabels.value = true;
enablePhysics.value = false;
layoutType.value = "force";
toggleAutoRefresh(true);
renderGraph();
};
onMounted(() => {
// Load vis.js library
const script = document.createElement("script");
script.src = "https://unpkg.com/vis-network@9.1.6/dist/vis-network.min.js";
script.onload = () => {
loadServiceGraph();
// Auto-refresh every 30 seconds
if (autoRefresh.value) {
refreshInterval = setInterval(loadServiceGraph, 30000);
}
};
document.head.appendChild(script);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://unpkg.com/vis-network@9.1.6/dist/dist/vis-network.min.css";
document.head.appendChild(link);
});
onBeforeUnmount(() => {
if (networkInstance) {
networkInstance.destroy();
}
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
return {
loading,
error,
graphContainer,
graphData,
filteredGraphData,
stats,
storeStats,
showSettings,
graphHeight,
autoRefresh,
showLabels,
enablePhysics,
lastUpdated,
searchFilter,
connectionTypeFilter,
selectedNode,
loadServiceGraph,
formatNumber,
applyFilters,
setLayout,
toggleAutoRefresh,
updatePhysics,
resetSettings,
getIncomingConnections,
getOutgoingConnections,
getNodeRequests,
getNodeErrors,
renderGraph,
};
},
});
</script>
<style scoped lang="scss">
.service-graph-container {
height: 100%;
padding: 16px;
background: #f5f5f5;
}
.stat-card {
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.graph-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.graph-container {
position: relative;
width: 100%;
background: #fafafa;
border-radius: 4px;
overflow: hidden;
}
#graph-network {
width: 100%;
height: 100%;
background: linear-gradient(to bottom, #ffffff 0%, #f9f9f9 100%);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.node-details-card {
position: absolute;
top: 16px;
right: 16px;
width: 280px;
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border-radius: 8px;
z-index: 1000;
}
code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2025 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "./http";
const serviceGraphService = {
/**
* Get service graph metrics in Prometheus format
*/
getMetrics: (orgId: string) => {
return http().get(`/api/${orgId}/traces/service_graph/metrics`);
},
/**
* Get service graph store statistics
*/
getStats: (orgId: string) => {
return http().get(`/api/${orgId}/traces/service_graph/stats`);
},
};
export default serviceGraphService;