mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 08:09:24 +00:00
feat: dag graph for llm traces (#10388)
This commit is contained in:
Generated
+12
-12
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -47,6 +47,8 @@ use crate::{
|
||||
service::{search as SearchService, traces},
|
||||
};
|
||||
|
||||
pub mod dag;
|
||||
|
||||
/// TracesIngest
|
||||
#[utoipa::path(
|
||||
post,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user