feat: dag graph for llm traces (#10388)

This commit is contained in:
Huaijin
2026-02-06 16:46:44 +08:00
committed by GitHub
parent 9aeb30ba60
commit b315f4ca08
9 changed files with 1270 additions and 20 deletions
Generated
+12 -12
View File
@@ -3487,12 +3487,12 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.3.11"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
"serde",
"serde_core",
]
[[package]]
@@ -6269,9 +6269,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-format"
@@ -10339,9 +10339,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.37"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -10349,22 +10349,22 @@ dependencies = [
"num-conv",
"num_threads",
"powerfmt",
"serde",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.2"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.19"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
+1
View File
@@ -1018,6 +1018,7 @@ where
|| path.contains("/format_query")
|| path.contains("/prometheus/api/v1/series")
|| path.contains("/traces/latest")
|| (path.contains("/traces/") && path.ends_with("/dag"))
|| path.contains("clusters")
|| path.contains("query_manager")
|| path.contains("/short")
+370
View File
@@ -0,0 +1,370 @@
// Copyright 2026 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 axum::{extract::Path, http::HeaderMap, response::Response};
use config::{get_config, meta::stream::StreamType, metrics, utils::json};
use hashbrown::HashMap;
use serde::Serialize;
use tracing::{Instrument, Span};
use crate::{
common::{
meta::http::HttpResponse as MetaHttpResponse,
utils::{
auth::UserEmail,
http::{get_or_create_trace_id, get_use_cache_from_request},
},
},
handler::http::{
extractors::Headers, request::search::error_utils::map_error_to_http_response,
},
service::search as SearchService,
};
/// GetTraceDAG
///
/// #{"ratelimit_module":"Traces", "ratelimit_module_operation":"list"}#
#[utoipa::path(
get,
path = "/{org_id}/{stream_name}/traces/{trace_id}/dag",
context_path = "/api",
tag = "Traces",
operation_id = "GetTraceDAG",
summary = "Get trace DAG structure",
description = "Retrieves the DAG (Directed Acyclic Graph) structure of all spans for a specific trace. Returns nodes (spans) and edges (parent-child relationships) that can be visualized as a trace execution graph. Each node contains span details like service name, operation, and status information.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("stream_name" = String, Path, description = "Stream name"),
("trace_id" = String, Path, description = "Trace ID"),
("start_time" = i64, Query, description = "start time in microseconds"),
("end_time" = i64, Query, description = "end time in microseconds"),
("timeout" = Option<i64>, Query, description = "timeout, seconds"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object, example = json!({
"trace_id": "12345678",
"nodes": [
{
"span_id": "span1",
"parent_span_id": null,
"service_name": "frontend",
"operation_name": "GET /api",
"span_status": "OK"
}
],
"edges": [
{
"from": "span1",
"to": "span2"
}
]
})),
(status = 400, description = "Failure", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn get_trace_dag(
Path((org_id, stream_name, trace_id)): Path<(String, String, String)>,
axum::extract::Query(query): axum::extract::Query<HashMap<String, String>>,
headers: HeaderMap,
Headers(user_email): Headers<UserEmail>,
) -> Response {
let start = std::time::Instant::now();
let cfg = get_config();
#[cfg(feature = "enterprise")]
{
if let Err(e) = crate::service::search::check_search_allowed(&org_id, Some(&stream_name)) {
return MetaHttpResponse::too_many_requests(e.to_string());
}
}
let (http_span, internal_trace_id) = if cfg.common.tracing_search_enabled {
let uuid_v7_trace_id = config::ider::generate_trace_id();
let span = tracing::info_span!(
"/api/{org_id}/{stream_name}/traces/{trace_id}/dag",
org_id = org_id.clone(),
stream_name = stream_name.clone(),
trace_id = uuid_v7_trace_id.clone()
);
(span, uuid_v7_trace_id)
} else {
let internal_trace_id = get_or_create_trace_id(&headers, &Span::none());
(Span::none(), internal_trace_id)
};
let user_id = &user_email.user_id;
// Check permissions on stream
#[cfg(feature = "enterprise")]
{
use o2_openfga::meta::mapping::OFGA_MODELS;
use crate::{
common::utils::auth::{AuthExtractor, is_root_user},
service::users::get_user,
};
if !is_root_user(user_id) {
let user: config::meta::user::User = get_user(Some(&org_id), user_id).await.unwrap();
let stream_type_str = StreamType::Traces.as_str();
if !crate::handler::http::auth::validator::check_permissions(
user_id,
AuthExtractor {
auth: "".to_string(),
method: "GET".to_string(),
o2_type: format!(
"{}:{}",
OFGA_MODELS
.get(stream_type_str)
.map_or(stream_type_str, |model| model.key),
stream_name
),
org_id: org_id.clone(),
bypass_check: false,
parent_id: "".to_string(),
},
user.role,
user.is_external,
)
.await
{
return MetaHttpResponse::forbidden("Unauthorized Access");
}
}
// Check permissions on stream ends
}
// Parse required start_time and end_time from query parameters
let start_time = match query.get("start_time") {
Some(v) => match v.parse::<i64>() {
Ok(val) => val,
Err(_) => return MetaHttpResponse::bad_request("Invalid start_time parameter"),
},
None => return MetaHttpResponse::bad_request("start_time parameter is required"),
};
let end_time = match query.get("end_time") {
Some(v) => match v.parse::<i64>() {
Ok(val) => val,
Err(_) => return MetaHttpResponse::bad_request("Invalid end_time parameter"),
},
None => return MetaHttpResponse::bad_request("end_time parameter is required"),
};
// Validate time range
if start_time >= end_time {
return MetaHttpResponse::bad_request("start_time must be less than end_time");
}
let timeout = query
.get("timeout")
.map_or(0, |v| v.parse::<i64>().unwrap_or(0));
// Query all spans for this trace_id
let query_sql = format!(
"SELECT span_id, trace_id, service_name, operation_name, span_status, \
reference_parent_span_id, start_time, end_time, _o2_llm_observation_type \
FROM {stream_name} \
WHERE trace_id = '{trace_id}'"
);
let req = config::meta::search::Request {
query: config::meta::search::Query {
sql: query_sql.to_string(),
from: 0,
size: 10000, // Large enough for most traces
start_time,
end_time,
quick_mode: false,
query_type: "".to_string(),
track_total_hits: false,
uses_zo_fn: false,
query_fn: None,
action_id: None,
skip_wal: false,
sampling_config: None,
sampling_ratio: None,
streaming_output: false,
streaming_id: None,
histogram_interval: 0,
},
encoding: config::meta::search::RequestEncoding::Empty,
regions: vec![],
clusters: vec![],
timeout,
search_type: None,
search_event_context: None,
use_cache: get_use_cache_from_request(&query),
clear_cache: false,
local_mode: None,
};
let stream_type = StreamType::Traces;
let user_id_opt = Some(user_id.to_string());
let search_res = SearchService::cache::search(
&internal_trace_id,
&org_id,
stream_type,
user_id_opt,
&req,
"".to_string(),
false,
None,
false,
)
.instrument(http_span)
.await;
let resp_search = match search_res {
Ok(res) => res,
Err(err) => {
let time = start.elapsed().as_secs_f64();
metrics::HTTP_RESPONSE_TIME
.with_label_values(&[
"/api/org/traces/dag",
"500",
&org_id,
stream_type.as_str(),
"",
"",
])
.observe(time);
metrics::HTTP_INCOMING_REQUESTS
.with_label_values(&[
"/api/org/traces/dag",
"500",
&org_id,
stream_type.as_str(),
"",
"",
])
.inc();
log::error!("get trace dag data error: {err:?}");
return map_error_to_http_response(&err, Some(internal_trace_id));
}
};
if resp_search.hits.is_empty() {
return MetaHttpResponse::not_found("Trace not found");
}
// Build DAG structure
let mut nodes: Vec<SpanNode> = Vec::with_capacity(resp_search.hits.len());
let mut edges: Vec<SpanEdge> = Vec::new();
for item in resp_search.hits {
let span_id = item
.get("span_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let parent_span_id = item
.get("reference_parent_span_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
// Create node
let node = SpanNode {
span_id: span_id.clone(),
parent_span_id: parent_span_id.clone(),
service_name: item
.get("service_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
operation_name: item
.get("operation_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
span_status: item
.get("span_status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
start_time: item.get("start_time").and_then(|v| v.as_i64()).unwrap_or(0),
end_time: item.get("end_time").and_then(|v| v.as_i64()).unwrap_or(0),
llm_observation_type: item
.get("_o2_llm_observation_type")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string()),
};
nodes.push(node);
// Create edge if there's a parent
if let Some(parent_id) = parent_span_id {
edges.push(SpanEdge {
from: parent_id,
to: span_id,
});
}
}
let time = start.elapsed().as_secs_f64();
metrics::HTTP_RESPONSE_TIME
.with_label_values(&[
"/api/org/traces/dag",
"200",
&org_id,
stream_type.as_str(),
"",
"",
])
.observe(time);
metrics::HTTP_INCOMING_REQUESTS
.with_label_values(&[
"/api/org/traces/dag",
"200",
&org_id,
stream_type.as_str(),
"",
"",
])
.inc();
let mut resp: HashMap<&str, json::Value> = HashMap::new();
resp.insert("took", json::Value::from((time * 1000.0) as usize));
resp.insert("trace_id", json::Value::from(trace_id));
resp.insert("nodes", json::to_value(nodes).unwrap());
resp.insert("edges", json::to_value(edges).unwrap());
resp.insert("internal_trace_id", json::Value::from(internal_trace_id));
MetaHttpResponse::json(resp)
}
#[derive(Debug, Serialize)]
struct SpanNode {
span_id: String,
parent_span_id: Option<String>,
service_name: String,
operation_name: String,
span_status: String,
start_time: i64,
end_time: i64,
llm_observation_type: Option<String>,
}
#[derive(Debug, Serialize)]
struct SpanEdge {
from: String,
to: String,
}
+2
View File
@@ -47,6 +47,8 @@ use crate::{
service::{search as SearchService, traces},
};
pub mod dag;
/// TracesIngest
#[utoipa::path(
post,
+1
View File
@@ -571,6 +571,7 @@ pub fn service_routes() -> Router {
// Traces
.route("/{org_id}/{stream_name}/traces/latest", get(traces::get_latest_traces))
.route("/{org_id}/{stream_name}/traces/{trace_id}/dag", get(traces::dag::get_trace_dag))
// Metrics
.route("/{org_id}/ingest/metrics/_json", post(metrics::ingest::json))
+59
View File
@@ -242,4 +242,63 @@ mod tests {
assert_eq!(output_cost, 0.0);
assert_eq!(total_cost, 0.0);
}
#[test]
fn test_calculate_token_count_known_model() {
// Test with a known OpenAI model
let prompt = "Hello, world!";
let token_count = calculate_token_count("gpt-4", prompt);
assert!(token_count > 0);
// "Hello, world!" should be encoded to a few tokens
assert!(token_count < 10);
}
#[test]
fn test_calculate_token_count_unknown_model() {
// Test with an unknown model (should fallback to o200k_base)
let prompt = "This is a test prompt";
let token_count = calculate_token_count("unknown-model-xyz", prompt);
assert!(token_count > 0);
// Should still return a valid token count using fallback encoding
}
#[test]
fn test_calculate_token_count_empty_prompt() {
// Test with empty prompt
let token_count = calculate_token_count("gpt-4", "");
// Empty prompt might still have some tokens (like BOS token) or be 0
assert!(token_count >= 0);
}
#[test]
fn test_calculate_token_count_long_prompt() {
// Test with a longer prompt
let prompt = "The quick brown fox jumps over the lazy dog. ".repeat(10);
let token_count = calculate_token_count("gpt-4", &prompt);
assert!(token_count > 0);
// Longer prompt should have more tokens
assert!(token_count > 10);
}
#[test]
fn test_calculate_token_count_special_characters() {
// Test with special characters and unicode
let prompt = "Hello! 你好 🌍 世界";
let token_count = calculate_token_count("gpt-4", prompt);
assert!(token_count > 0);
}
#[test]
fn test_calculate_token_count_different_models() {
// Test that different models produce consistent results (or at least valid results)
let prompt = "Test prompt";
let gpt4_count = calculate_token_count("gpt-4", prompt);
let gpt35_count = calculate_token_count("gpt-3.5-turbo", prompt);
let claude_count = calculate_token_count("claude-3-5-sonnet", prompt);
// All should return valid token counts
assert!(gpt4_count > 0);
assert!(gpt35_count > 0);
assert!(claude_count > 0);
}
}
+633
View File
@@ -0,0 +1,633 @@
<!-- Copyright 2026 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/>.
-->
<template>
<div class="trace-dag-container">
<div v-if="isLoading" class="flex items-center justify-center column q-pa-xl loading-container">
<q-spinner color="primary" size="50px" />
<div class="q-mt-md text-grey-7">Loading trace DAG...</div>
</div>
<div v-else-if="error" class="error-message q-pa-md">
<q-banner class="bg-negative text-white">
<template #avatar>
<q-icon name="error" color="white" />
</template>
Failed to load DAG: {{ error }}
</q-banner>
</div>
<div v-else-if="!dagData || !dagData.nodes || dagData.nodes.length === 0" class="flex items-center justify-center column q-pa-xl empty-container">
<q-icon name="info" size="48px" color="grey-5" />
<div class="q-mt-md text-grey-7">No DAG data available</div>
</div>
<div v-else class="dag-wrapper">
<VueFlow
:nodes="nodes"
:edges="edges"
:default-viewport="{ zoom: 0.8, x: 0, y: 30 }"
:min-zoom="0.2"
:max-zoom="3"
fit-view-on-init
:fit-view-options="{ padding: 0.3, minZoom: 0.3, maxZoom: 0.7 }"
class="trace-dag-flow"
>
<Background pattern-color="#aaa" :gap="16" />
<Controls />
<template #node-custom="{ data }">
<Handle type="target" :position="Position.Top" class="dag-handle" />
<div
class="custom-node"
:class="[
getObservationTypeClass(data.llm_observation_type),
{
'node-error': data.span_status === 'ERROR',
'node-ok': data.span_status === 'OK' && !data.llm_observation_type,
}
]"
@click="handleNodeClick(data.span_id)"
>
<div class="node-operation" :class="getObservationTypeTextClass(data.llm_observation_type)">{{ data.operation_name }}</div>
<q-chip
v-if="data.span_status === 'ERROR'"
dense
size="xs"
color="negative"
text-color="white"
class="error-chip"
>
ERR
</q-chip>
</div>
<Handle type="source" :position="Position.Bottom" class="dag-handle" />
</template>
</VueFlow>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, nextTick } from "vue";
import { VueFlow, Position, MarkerType, Handle, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import { useStore } from "vuex";
import searchService from "@/services/search";
// VueFlow CSS imports
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
interface SpanNode {
span_id: string;
parent_span_id: string | null;
service_name: string;
operation_name: string;
span_status: string;
start_time: number;
end_time: number;
llm_observation_type: string | null;
}
interface SpanEdge {
from: string;
to: string;
}
interface DAGResponse {
trace_id: string;
nodes: SpanNode[];
edges: SpanEdge[];
}
export default defineComponent({
name: "TraceDAG",
components: {
VueFlow,
Background,
Controls,
Handle,
},
props: {
traceId: {
type: String,
required: true,
},
streamName: {
type: String,
required: true,
},
startTime: {
type: Number,
required: true,
},
endTime: {
type: Number,
required: true,
},
sidebarOpen: {
type: Boolean,
default: false,
},
},
emits: ["node-click"],
setup(props, { emit }) {
const store = useStore();
const isLoading = ref(true);
const error = ref<string | null>(null);
const dagData = ref<DAGResponse | null>(null);
// Get VueFlow instance for fitView functionality
const { fitView } = useVueFlow();
// Top-down tree layout algorithm
const calculateLayout = (nodesData: SpanNode[], edgesData: SpanEdge[]) => {
const nodeWidth = 140;
const nodeHeight = 28;
const horizontalGap = 16;
const verticalGap = 36;
// Build adjacency list and find root nodes
const children: Map<string, string[]> = new Map();
const hasParent: Set<string> = new Set();
const nodeMap: Map<string, SpanNode> = new Map();
nodesData.forEach((node) => {
nodeMap.set(node.span_id, node);
children.set(node.span_id, []);
});
edgesData.forEach((edge) => {
const parentChildren = children.get(edge.from) || [];
parentChildren.push(edge.to);
children.set(edge.from, parentChildren);
hasParent.add(edge.to);
});
// Sort children by start_time for proper preorder traversal ordering
children.forEach((childIds, parentId) => {
childIds.sort((a, b) => {
const nodeA = nodeMap.get(a);
const nodeB = nodeMap.get(b);
return (nodeA?.start_time || 0) - (nodeB?.start_time || 0);
});
});
// Find root nodes (nodes without parents) and sort by start_time
const roots = nodesData
.filter((node) => !hasParent.has(node.span_id))
.sort((a, b) => (a.start_time || 0) - (b.start_time || 0));
// Assign levels using BFS
const levels: Map<string, number> = new Map();
const queue: { id: string; level: number }[] = roots.map((r) => ({
id: r.span_id,
level: 0,
}));
while (queue.length > 0) {
const { id, level } = queue.shift()!;
if (levels.has(id)) continue;
levels.set(id, level);
const childNodes = children.get(id) || [];
childNodes.forEach((childId) => {
if (!levels.has(childId)) {
queue.push({ id: childId, level: level + 1 });
}
});
}
// Handle disconnected nodes
nodesData.forEach((node) => {
if (!levels.has(node.span_id)) {
levels.set(node.span_id, 0);
}
});
// Group nodes by level
const levelGroups: Map<number, string[]> = new Map();
levels.forEach((level, id) => {
const group = levelGroups.get(level) || [];
group.push(id);
levelGroups.set(level, group);
});
// Calculate subtree widths for better positioning
const subtreeWidth: Map<string, number> = new Map();
const calculateSubtreeWidth = (nodeId: string): number => {
const childNodes = children.get(nodeId) || [];
if (childNodes.length === 0) {
subtreeWidth.set(nodeId, nodeWidth);
return nodeWidth;
}
let totalWidth = 0;
childNodes.forEach((childId) => {
totalWidth += calculateSubtreeWidth(childId);
});
totalWidth += (childNodes.length - 1) * horizontalGap;
const width = Math.max(nodeWidth, totalWidth);
subtreeWidth.set(nodeId, width);
return width;
};
// Calculate subtree widths starting from roots
roots.forEach((root) => calculateSubtreeWidth(root.span_id));
// Calculate positions using tree layout
const positions: Map<string, { x: number; y: number }> = new Map();
const positionNode = (nodeId: string, x: number, y: number) => {
positions.set(nodeId, { x, y });
const childNodes = children.get(nodeId) || [];
if (childNodes.length === 0) return;
// Calculate total width of children
let totalChildWidth = 0;
childNodes.forEach((childId) => {
totalChildWidth += subtreeWidth.get(childId) || nodeWidth;
});
totalChildWidth += (childNodes.length - 1) * horizontalGap;
// Position children centered under parent
let childX = x - totalChildWidth / 2 + (subtreeWidth.get(childNodes[0]) || nodeWidth) / 2;
const childY = y + nodeHeight + verticalGap;
childNodes.forEach((childId, index) => {
const childWidth = subtreeWidth.get(childId) || nodeWidth;
if (index > 0) {
const prevChildWidth = subtreeWidth.get(childNodes[index - 1]) || nodeWidth;
childX += prevChildWidth / 2 + horizontalGap + childWidth / 2;
}
positionNode(childId, childX, childY);
});
};
// Position all trees starting from roots
let rootX = 0;
roots.forEach((root, index) => {
const rootWidth = subtreeWidth.get(root.span_id) || nodeWidth;
if (index > 0) {
const prevRootWidth = subtreeWidth.get(roots[index - 1].span_id) || nodeWidth;
rootX += prevRootWidth / 2 + horizontalGap * 2 + rootWidth / 2;
}
positionNode(root.span_id, rootX, 0);
});
// Center the entire tree
const allX = Array.from(positions.values()).map(p => p.x);
const minX = Math.min(...allX);
const maxX = Math.max(...allX);
const offsetX = -(minX + maxX) / 2;
positions.forEach((pos, id) => {
positions.set(id, { x: pos.x + offsetX, y: pos.y });
});
return positions;
};
const nodes = computed(() => {
if (!dagData.value || !dagData.value.nodes) return [];
const positions = calculateLayout(dagData.value.nodes, dagData.value.edges || []);
return dagData.value.nodes.map((node) => ({
id: node.span_id,
type: "custom",
position: positions.get(node.span_id) || { x: 0, y: 0 },
data: node,
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
}));
});
const edges = computed(() => {
if (!dagData.value) return [];
return dagData.value.edges.map((edge) => ({
id: `${edge.from}-${edge.to}`,
source: edge.from,
target: edge.to,
type: "default",
animated: false,
markerEnd: MarkerType.ArrowClosed,
style: {
strokeWidth: 1,
stroke: "#888",
},
}));
});
const fetchDAG = async () => {
try {
isLoading.value = true;
error.value = null;
const org = store.state.selectedOrganization.identifier;
const response = await searchService.getTraceDAG(
org,
props.streamName,
props.traceId,
props.startTime,
props.endTime,
);
dagData.value = response.data;
} catch (err: any) {
console.error("[TraceDAG] Failed to fetch DAG:", err);
error.value = err.response?.data?.message || err.message || "Unknown error occurred";
} finally {
isLoading.value = false;
}
};
// Watch for prop changes and refetch
watch(
() => [props.traceId, props.streamName, props.startTime, props.endTime] as const,
([traceId, streamName, startTime, endTime]) => {
// Validate all props before fetching
if (
!traceId ||
!streamName ||
startTime == null ||
endTime == null ||
typeof startTime !== 'number' ||
typeof endTime !== 'number' ||
startTime >= endTime
) {
error.value = "Invalid parameters for DAG fetch";
isLoading.value = false;
return;
}
fetchDAG();
},
{ immediate: true }
);
const handleNodeClick = (spanId: string) => {
emit("node-click", spanId);
};
// Known LLM observation types (from ObservationType enum)
const knownObservationTypes = new Set([
'generation', 'span', 'tool', 'agent', 'chain', 'retriever',
'task', 'evaluator', 'workflow', 'embedding', 'rerank', 'guardrail', 'event',
]);
const getObservationTypeClass = (type: string | null): string => {
if (!type) return '';
const key = type.toLowerCase();
if (knownObservationTypes.has(key)) return `node-llm-${key}`;
return 'node-llm-default';
};
const getObservationTypeTextClass = (type: string | null): string => {
if (!type) return '';
const key = type.toLowerCase();
if (knownObservationTypes.has(key)) return `node-llm-text-${key}`;
return 'node-llm-text-default';
};
// Watch for sidebar state changes and re-center the DAG
watch(
() => props.sidebarOpen,
() => {
// When sidebar state changes, wait for DOM to update then fit the view
nextTick(() => {
// Small delay to allow container resize to complete
setTimeout(() => {
fitView({ padding: 0.3, duration: 300 });
}, 50);
});
}
);
return {
isLoading,
error,
dagData,
nodes,
edges,
Position,
handleNodeClick,
getObservationTypeClass,
getObservationTypeTextClass,
};
},
});
</script>
<style lang="scss">
.trace-dag-container {
width: 100%;
height: 100%;
min-height: 500px;
}
.loading-container,
.empty-container {
height: 500px;
}
.dag-wrapper {
width: 100%;
height: 100%;
min-height: 600px;
border: 1px solid #e0e0e0;
border-radius: 4px;
position: relative;
}
.trace-dag-flow {
width: 100%;
height: 100%;
background-color: #fafafa;
.vue-flow__node-custom {
.custom-node {
padding: 4px 8px;
border-radius: 4px;
background: white;
border: 1.5px solid #1976d2;
min-width: 60px;
max-width: 140px;
min-height: 22px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
transition: all 0.15s ease;
cursor: pointer;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}
&.node-error {
border-color: #c62828;
background: #ffebee;
}
&.node-ok {
border-color: #2e7d32;
}
// LLM observation type node colors (consistent with llmUtils getObservationTypeColor)
&.node-llm-generation { border-color: #4caf50; background: #e8f5e9; } // green
&.node-llm-embedding { border-color: #2196f3; background: #e3f2fd; } // blue
&.node-llm-agent { border-color: #9c27b0; background: #f3e5f5; } // purple
&.node-llm-tool { border-color: #ff9800; background: #fff3e0; } // orange
&.node-llm-chain { border-color: #3f51b5; background: #e8eaf6; } // indigo
&.node-llm-retriever { border-color: #00bcd4; background: #e0f7fa; } // cyan
&.node-llm-task { border-color: #009688; background: #e0f2f1; } // teal
&.node-llm-evaluator { border-color: #e91e63; background: #fce4ec; } // pink
&.node-llm-workflow { border-color: #673ab7; background: #ede7f6; } // deep-purple
&.node-llm-rerank { border-color: #03a9f4; background: #e1f5fe; } // light-blue
&.node-llm-guardrail { border-color: #f44336; background: #ffebee; } // red
&.node-llm-span { border-color: #9e9e9e; background: #f5f5f5; } // grey
&.node-llm-event { border-color: #ffc107; background: #fff8e1; } // amber
&.node-llm-default { border-color: #9e9e9e; background: #fafafa; }
}
.node-operation {
font-size: 12px;
color: #1976d2;
font-weight: 500;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 124px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.node-llm-text-generation { color: #388e3c; } // green-dark
&.node-llm-text-embedding { color: #1976d2; } // blue-dark
&.node-llm-text-agent { color: #7b1fa2; } // purple-dark
&.node-llm-text-tool { color: #e65100; } // orange-dark
&.node-llm-text-chain { color: #283593; } // indigo-dark
&.node-llm-text-retriever { color: #00838f; } // cyan-dark
&.node-llm-text-task { color: #00796b; } // teal-dark
&.node-llm-text-evaluator { color: #c2185b; } // pink-dark
&.node-llm-text-workflow { color: #4527a0; } // deep-purple-dark
&.node-llm-text-rerank { color: #0277bd; } // light-blue-dark
&.node-llm-text-guardrail { color: #c62828; } // red-dark
&.node-llm-text-span { color: #616161; } // grey-dark
&.node-llm-text-event { color: #f57f17; } // amber-dark
&.node-llm-text-default { color: #757575; }
}
.dag-handle {
width: 5px;
height: 5px;
background: #1976d2;
border: 1px solid white;
border-radius: 50%;
}
.error-chip {
font-size: 9px;
height: 12px;
margin-top: 1px;
}
}
}
.error-message {
padding: 20px;
}
.body--dark {
.dag-wrapper {
border-color: #444;
}
.trace-dag-flow {
background-color: #1e1e1e !important;
.vue-flow__background {
background-color: #1e1e1e !important;
}
.vue-flow__node-custom {
.custom-node {
background: #2a2a2a;
border-color: #64b5f6;
color: #e0e0e0;
border-width: 1.5px;
max-width: 140px;
&.node-error {
border-color: #ef5350;
background: #3a1a1a;
}
&.node-ok {
border-color: #66bb6a;
}
// LLM observation type dark mode colors (consistent with llmUtils)
&.node-llm-generation { border-color: #66bb6a; background: #1a2e1a; } // green
&.node-llm-embedding { border-color: #64b5f6; background: #1a2a3a; } // blue
&.node-llm-agent { border-color: #ce93d8; background: #2a1a2e; } // purple
&.node-llm-tool { border-color: #ffb74d; background: #2e2218; } // orange
&.node-llm-chain { border-color: #7986cb; background: #1a1a2e; } // indigo
&.node-llm-retriever { border-color: #4dd0e1; background: #1a2a2e; } // cyan
&.node-llm-task { border-color: #4db6ac; background: #1a2e2a; } // teal
&.node-llm-evaluator { border-color: #f48fb1; background: #2e1a22; } // pink
&.node-llm-workflow { border-color: #b39ddb; background: #221a2e; } // deep-purple
&.node-llm-rerank { border-color: #4fc3f7; background: #1a2a3a; } // light-blue
&.node-llm-guardrail { border-color: #ef5350; background: #2e1a1a; } // red
&.node-llm-span { border-color: #9e9e9e; background: #262626; } // grey
&.node-llm-event { border-color: #ffd54f; background: #2e2a18; } // amber
&.node-llm-default { border-color: #9e9e9e; background: #262626; }
}
.node-operation {
color: #90caf9;
font-size: 12px;
max-width: 124px;
&.node-llm-text-generation { color: #81c784; } // green-light
&.node-llm-text-embedding { color: #90caf9; } // blue-light
&.node-llm-text-agent { color: #ce93d8; } // purple-light
&.node-llm-text-tool { color: #ffcc80; } // orange-light
&.node-llm-text-chain { color: #9fa8da; } // indigo-light
&.node-llm-text-retriever { color: #80deea; } // cyan-light
&.node-llm-text-task { color: #80cbc4; } // teal-light
&.node-llm-text-evaluator { color: #f48fb1; } // pink-light
&.node-llm-text-workflow { color: #b39ddb; } // deep-purple-light
&.node-llm-text-rerank { color: #81d4fa; } // light-blue-light
&.node-llm-text-guardrail { color: #ef9a9a; } // red-light
&.node-llm-text-span { color: #bdbdbd; } // grey-light
&.node-llm-text-event { color: #ffe082; } // amber-light
&.node-llm-text-default { color: #bdbdbd; }
}
}
}
}
</style>
+182 -8
View File
@@ -376,13 +376,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="parentContainer"
>
<div class="trace-tree-wrapper card-container">
<trace-header
data-test="trace-details-header"
:baseTracePosition="baseTracePosition"
:splitterWidth="leftWidth"
@resize-start="startResize"
/>
<div style="display: flex; flex: 1; min-height: 0">
<!-- Tabs for Timeline/DAG views - DAG only shown for LLM traces -->
<q-tabs
v-if="hasLLMSpans"
v-model="activeTab"
dense
class="text-grey"
active-color="primary"
indicator-color="primary"
align="left"
narrow-indicator
>
<q-tab name="timeline" label="Timeline" data-test="trace-details-timeline-tab" />
<q-tab name="dag" label="DAG" data-test="trace-details-dag-tab" />
</q-tabs>
<q-separator v-if="hasLLMSpans" />
<!-- Timeline View - show when no LLM spans OR when timeline tab is active -->
<div v-if="!hasLLMSpans || activeTab === 'timeline'" style="display: flex; flex-direction: column; flex: 1; min-height: 0">
<trace-header
data-test="trace-details-header"
:baseTracePosition="baseTracePosition"
:splitterWidth="leftWidth"
@resize-start="startResize"
/>
<div style="display: flex; flex: 1; min-height: 0">
<div class="relative-position trace-content-scroll">
<div
class="trace-tree-container"
@@ -447,6 +465,57 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
</div>
</div>
<!-- DAG View - only for LLM traces -->
<div v-if="hasLLMSpans && activeTab === 'dag'" style="display: flex; flex: 1; min-height: 0;">
<div
class="dag-left-panel"
:style="{
width: isSidebarOpen && (selectedSpanId || showTraceDetails) ? `${dagLeftWidth}%` : '100%',
minWidth: '200px'
}"
>
<TraceDAG
data-test="trace-details-dag"
:traceId="effectiveSpanList[0]?.trace_id || ''"
:streamName="currentTraceStreamName || 'default'"
:startTime="effectiveTimeRange.from || 0"
:endTime="effectiveTimeRange.to || 0"
:sidebarOpen="isSidebarOpen && (!!selectedSpanId || showTraceDetails)"
@node-click="handleDAGNodeClick"
/>
</div>
<!-- Resizable divider -->
<div
v-if="isSidebarOpen && (selectedSpanId || showTraceDetails)"
class="dag-resizer"
@mousedown="startDagResize"
>
<div class="dag-resizer-line"></div>
</div>
<div
v-if="isSidebarOpen && (selectedSpanId || showTraceDetails)"
class="dag-right-panel"
:style="{
width: `${100 - dagLeftWidth}%`,
minWidth: '300px'
}"
>
<trace-details-sidebar
data-test="trace-details-dag-sidebar"
:span="spanMap[selectedSpanId as string]"
:baseTracePosition="baseTracePosition"
:search-query="searchQuery"
:stream-name="currentTraceStreamName"
:service-streams-enabled="serviceStreamsEnabled"
:parent-mode="mode"
@view-logs="redirectToLogs"
@close="closeSidebar"
@open-trace="openTraceLink"
/>
</div>
</div>
</div>
</div>
</div>
@@ -492,6 +561,7 @@ import useTraces from "@/composables/useTraces";
import { computed } from "vue";
import TraceDetailsSidebar from "./TraceDetailsSidebar.vue";
import TraceTree from "./TraceTree.vue";
import TraceDAG from "./TraceDAG.vue";
import TraceHeader from "./TraceHeader.vue";
import { useStore } from "vuex";
import {
@@ -521,6 +591,7 @@ import useNotifications from "@/composables/useNotifications";
import {
parseUsageDetails,
parseCostDetails,
isLLMTrace,
} from "@/utils/llmUtils";
export default defineComponent({
@@ -595,6 +666,7 @@ export default defineComponent({
ShareButton,
TraceDetailsSidebar,
TraceTree,
TraceDAG,
TraceHeader,
TraceTimelineIcon,
ServiceMapIcon,
@@ -607,6 +679,9 @@ export default defineComponent({
setup(props, { emit }) {
const traceTree: any = ref([]);
const spanMap: any = ref({});
const activeTab = ref("timeline");
const { searchObj, getUrlQueryParams } = useTraces();
const baseTracePosition: any = ref({});
const collapseMapping: any = ref({});
@@ -680,6 +755,12 @@ export default defineComponent({
const throttledResizing = ref<any>(null);
// DAG panel resize state
const dagLeftWidth: Ref<number> = ref(50); // percentage
const dagInitialX: Ref<number> = ref(0);
const dagInitialWidth: Ref<number> = ref(0);
const throttledDagResizing = ref<any>(null);
// Calculate sidebar width based on leftWidth
// Sidebar should take ~84% of the remaining space after left panel
const sidebarWidth = computed(() => {
@@ -789,6 +870,13 @@ export default defineComponent({
return searchObj.data.traceDetails.spanList;
});
// Check if the trace contains any LLM spans
const hasLLMSpans = computed(() => {
const spans = effectiveSpanList.value;
if (!spans || spans.length === 0) return false;
return spans.some((span: any) => isLLMTrace(span));
});
const showTraceDetails = ref(false);
const currentIndex = ref(0);
const searchResults = ref(0);
@@ -1751,6 +1839,33 @@ export default defineComponent({
document.body.classList.remove("no-select");
};
// DAG panel resize handlers
const startDagResize = (event: MouseEvent) => {
dagInitialX.value = event.clientX;
dagInitialWidth.value = dagLeftWidth.value;
throttledDagResizing.value = throttle(dagResizing, 16);
window.addEventListener("mousemove", throttledDagResizing.value);
window.addEventListener("mouseup", stopDagResize);
document.body.classList.add("no-select");
};
const dagResizing = (event: MouseEvent) => {
if (!parentContainer.value) return;
const containerWidth = parentContainer.value.clientWidth;
const deltaX = event.clientX - dagInitialX.value;
const deltaPercent = (deltaX / containerWidth) * 100;
const newWidth = dagInitialWidth.value + deltaPercent;
// Constrain between 20% and 80%
dagLeftWidth.value = Math.max(20, Math.min(80, newWidth));
};
const stopDagResize = () => {
window.removeEventListener("mousemove", throttledDagResizing.value);
window.removeEventListener("mouseup", stopDagResize);
document.body.classList.remove("no-select");
};
const toggleTimeline = () => {
isTimelineExpanded.value = !isTimelineExpanded.value;
};
@@ -1872,6 +1987,10 @@ export default defineComponent({
}
};
const handleDAGNodeClick = (spanId: string) => {
updateSelectedSpan(spanId);
};
const handleBackOrClose = () => {
if (props.mode === "embedded") {
emit("close");
@@ -1928,6 +2047,7 @@ export default defineComponent({
return {
router,
t,
activeTab,
traceTree,
collapseMapping,
traceRootSpan,
@@ -2010,6 +2130,12 @@ export default defineComponent({
effectiveSpanList,
// New event handlers
handleBackOrClose,
handleDAGNodeClick,
// DAG resize
dagLeftWidth,
startDagResize,
// LLM traces check
hasLLMSpans,
};
},
});
@@ -2068,11 +2194,15 @@ $traceChartCollapseHeight: 42px;
min-height: 0;
position: relative;
padding-bottom: 0.5rem;
display: flex;
flex-direction: column;
flex: 1;
}
.trace-tree-wrapper {
overflow: hidden;
height: calc(100% - 2.5rem);
flex: 1;
min-height: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -2113,6 +2243,50 @@ $traceChartCollapseHeight: 42px;
.toolbar-operation-name {
max-width: 225px;
}
.dag-left-panel {
height: calc(100vh - 200px);
padding: 16px;
min-width: 0;
overflow: hidden;
}
.dag-right-panel {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.dag-resizer {
width: 8px;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
z-index: 10;
&:hover .dag-resizer-line {
background-color: var(--o2-theme-color, #1976d2);
}
}
.dag-resizer-line {
width: 3px;
height: 100%;
background-color: #e0e0e0;
border-radius: 2px;
transition: background-color 0.2s ease;
}
body.body--dark .dag-resizer-line {
background-color: #3c3c3c;
}
body.body--dark .dag-resizer:hover .dag-resizer-line {
background-color: #90caf9;
}
</style>
<style lang="scss">
// Prevent parent containers from adding scrollbars
+10
View File
@@ -294,6 +294,16 @@ const search = {
const url = `/api/${org_identifier}/${stream_name}/traces/latest?filter=${filter}&start_time=${start_time}&end_time=${end_time}&from=${from}&size=${size}`;
return http().get(url);
},
getTraceDAG: (
org_identifier: string,
stream_name: string,
trace_id: string,
start_time: number,
end_time: number
) => {
const url = `/api/${org_identifier}/${stream_name}/traces/${trace_id}/dag?start_time=${start_time}&end_time=${end_time}`;
return http().get(url);
},
partition: ({
org_identifier,
query,