feat: traces critical path, RED insights, span percentiles, request-scoped catalog, span→profile link and saved views (#15073)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Prabhat Sharma
2026-10-05 21:49:02 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent 7d70a13e02
commit c12309a2be
89 changed files with 9257 additions and 1075 deletions
@@ -16628,6 +16628,8 @@
"queryManagement": "e804e3542cae3915",
"queryManagementDesc": "f1bc895b2f1da57e",
"recommendedByFieldCoverage": "a109c01355662af8",
"redInsightsEnabledHelp": "766c0400c24ccc99",
"redInsightsEnabledLabel": "4a18f94f3f6afd29",
"regexPatternList": {
"bulkDeleteFailed": "36c7f0d7791212dd",
"bulkDeleteMessage": "af717a4ac4d27610",
@@ -16741,6 +16743,7 @@
"themeManagementDescription": "5024019f661fc3dd",
"toggleIngestionLogsLabel": "d62acc58665cf9bb",
"traceIdFieldName": "f1fbe93390eb5290",
"tracesHeading": "998741a58ed2b411",
"trialExtensionMinWeek": "e174ca7c2a4417c5",
"trial_expiry": "03378fba7bf9c9ae",
"updateAiCreditsFailed": "5754421c67124875",
@@ -19579,6 +19582,7 @@
"applyAndSearch": "c7de18087c748cfb",
"applyPendingFilters": "ce1202e0d05abe3b",
"backToLogs": "ed627b7ddd4e0cca",
"backToResults": "c7ef0e278b1e08c0",
"backToTraces": "f03da00bbeb8113f",
"clearErrorFilter": "b634835f3e3a5324",
"collapseFields": "166bd3007d204cc4",
@@ -19608,6 +19612,7 @@
},
"correlationFailed": "47638dd4bdeaee84",
"correlationServiceNotFound": "77ea7bc4d298d205",
"criticalPath": "bfbcf6ab87bc0dc6",
"dbSpanDetails": {
"batchSize": "c4857cd1742f94da",
"database": "fa7fe67124e94375",
@@ -19624,6 +19629,7 @@
"user": "b512d97e7cbf97c2",
"viewQueryTrend": "df5dc6da572cfc5a"
},
"drillDown": "682c25289fd57604",
"errLabel": "af440ad05977eb32",
"error": "d98ee0e5f9399db9",
"errorCount": "6b8c59fe228558f0",
@@ -19796,7 +19802,9 @@
"operation": "0f044feb6ca7d676",
"p50Median": "76661f0c8ac6ce54",
"previousMatch": "daa2f8c3fc897cc7",
"redMetrics": "a9d1ab9a2d6d4dac",
"reviewAndApplyFilters": "28a6fd8929f1ffa5",
"savedViewStreamMissing": "d9d331cfdc12f282",
"searchBar": {
"streamNotFound": "b46fc36172de7113"
},
@@ -19921,10 +19929,22 @@
"errors": "cb702378f31507ef",
"maxDuration": "e5b10d63b3485042",
"requests": "ada27592c9578e90",
"requestsTooltip": "e5d6ed86bca68fdd",
"serviceName": "d677190e0a9990e7",
"status": "920e413c7d411b61"
},
"filterPlaceholder": "6c403ddc8a77c64c",
"insights": {
"scoreAbove": "6015543c308e3576",
"signals": {
"errors": "e4fe6202728fa010",
"p95": "a81f378f0a8b5949",
"rate": "9535754daaf07fae"
},
"title": "2a932f90172e99ea",
"viewCharts": "f91cf6c6263dc004",
"viewTraces": "998741a58ed2b411"
},
"legend": {
"title": "bf7d539c44f17179"
},
@@ -20171,6 +20191,13 @@
"modelParameters": "166cfbd5b6effb00",
"noDataAvailable": "d2d2d48c815b0539",
"notScoredYet": "a83f5a22f4cccac3",
"operationBandAboveP99": "f9bf10b75fbbdee7",
"operationBandBelowP50": "39d15e603495c20f",
"operationBandP50ToP75": "24c88939d8a0ecb1",
"operationBandP75ToP90": "1a83ac65f5b53949",
"operationBandP90ToP99": "832d86d902fc75cf",
"operationPercentileBand": "8367057943fd3f77",
"operationPercentilesTooltip": "3ccec31a0be1b901",
"out": "9a3c5460432ba66a",
"output": "b2439bcb8dee14b6",
"outputTokens": "a0a1074faa44dfa0",
@@ -20231,6 +20258,7 @@
"viewFilters": "6fae22556dc2d946",
"viewLogs": "e087f046dc3cda9a",
"viewLogsUnavailable": "e2468fc4f70cc910",
"viewProfile": "d4788f256f730423",
"waterfall": "6b53bc225e0aae1d"
},
"user": {
@@ -135,6 +135,10 @@ pub async fn create(
data.usage_stream_enabled = usage_stream_enabled;
}
if merge_red_insights_enabled(&mut data, settings.red_insights_enabled) {
field_found = true;
}
if let Some(cross_links) = settings.cross_links {
for link in &cross_links {
if link.name.is_empty() {
@@ -365,8 +369,31 @@ pub async fn delete_logo_text() -> Response {
(StatusCode::FORBIDDEN, Json("Not Supported")).into_response()
}
fn merge_red_insights_enabled(data: &mut OrganizationSetting, value: Option<bool>) -> bool {
let Some(enabled) = value else {
return false;
};
data.red_insights_enabled = enabled;
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merge_red_insights_enabled_sets_and_leaves() {
let mut data = OrganizationSetting::default();
assert!(!merge_red_insights_enabled(&mut data, None));
assert!(!data.red_insights_enabled);
assert!(merge_red_insights_enabled(&mut data, Some(true)));
assert!(data.red_insights_enabled);
assert!(!merge_red_insights_enabled(&mut data, None));
assert!(data.red_insights_enabled);
assert!(merge_red_insights_enabled(&mut data, Some(false)));
assert!(!data.red_insights_enabled);
}
#[test]
fn test_max_series_per_query_validation_valid_values() {
// Test minimum valid value
+33 -2
View File
@@ -108,17 +108,27 @@ pub async fn get_profiles_meta(
timeout,
};
let [services_trace_id, types_trace_id] = sub_query_trace_ids(&request_trace_id);
let services_params = ProfilesSqlParams {
request_trace_id: &services_trace_id,
..sql_params
};
let types_params = ProfilesSqlParams {
request_trace_id: &types_trace_id,
..sql_params
};
// Only DISTINCT columns present in schema — async-profiler data often has no service_name.
let services_fut = async {
if schema_fields.iter().any(|f| f == "service_name") {
distinct_values(&sql_params, &stream, "service_name", http_span.clone()).await
distinct_values(&services_params, &stream, "service_name", http_span.clone()).await
} else {
Ok(Vec::new())
}
};
let profile_types_fut = async {
if schema_fields.iter().any(|f| f == "profile_type") {
load_profile_types(&sql_params, &stream, http_span.clone()).await
load_profile_types(&types_params, &stream, http_span.clone()).await
} else {
Ok(Vec::new())
}
@@ -144,6 +154,14 @@ pub async fn get_profiles_meta(
.into_response()
}
/// Concurrent searches need their own ids: the query manager keys running searches by trace_id.
fn sub_query_trace_ids(request_trace_id: &str) -> [String; 2] {
[
format!("{request_trace_id}-services"),
format!("{request_trace_id}-types"),
]
}
async fn distinct_values(
params: &ProfilesSqlParams<'_>,
stream: &str,
@@ -208,3 +226,16 @@ async fn load_profile_types(
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sub_query_trace_ids_are_distinct_and_keep_the_request_prefix() {
let [services, types] = sub_query_trace_ids("req123");
assert_ne!(services, types);
assert!(services.starts_with("req123"), "{services}");
assert!(types.starts_with("req123"), "{types}");
}
}
+123 -1
View File
@@ -251,18 +251,65 @@ mod tests {
Router,
body::Body,
http::{Request, StatusCode},
routing::post,
routing::{post, put},
};
use serde_json::{Value, json};
use tower::ServiceExt;
use super::*;
fn app() -> Router {
Router::new()
.route("/{org_id}/savedviews", post(create_view).get(get_views))
.route(
"/{org_id}/savedviews/{view_id}",
put(update_view).get(get_view),
)
}
async fn call(method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let req = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(body.map_or_else(Body::empty, |b| Body::from(b.to_string())))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
(
status,
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
)
}
async fn create(org: &str, name: &str, view_type: Option<&str>) -> (StatusCode, Value) {
let mut body = json!({"data": {"v": 1}, "view_name": name});
if let Some(t) = view_type {
body["view_type"] = json!(t);
}
call("POST", &format!("/{org}/savedviews"), Some(body)).await
}
async fn list(org: &str) -> Vec<Value> {
let (status, body) = call("GET", &format!("/{org}/savedviews"), None).await;
assert_eq!(status, StatusCode::OK);
body["views"].as_array().cloned().unwrap_or_default()
}
fn new_org() -> String {
format!("svorg{}", config::ider::uuid()).to_lowercase()
}
#[tokio::test]
async fn test_create_view_post() {
infra::db::create_table().await.unwrap();
let payload = CreateViewRequest {
data: "base64-encoded-data".into(),
view_name: format!("query-for-blah-{}", config::ider::uuid()),
view_type: None,
};
let app = Router::new().route("/{org_id}/savedviews", post(create_view));
let req = Request::builder()
@@ -274,4 +321,79 @@ mod tests {
let resp = app.oneshot(req).await.unwrap();
assert!(resp.status() == StatusCode::OK || resp.status().is_success());
}
#[tokio::test]
async fn test_traces_view_type_is_stored_listed_and_kept_on_update() {
infra::db::create_table().await.unwrap();
let org = new_org();
let (status, created) = create(&org, "checkout errors", Some("traces")).await;
assert_eq!(status, StatusCode::OK);
let view_id = created["view_id"].as_str().unwrap().to_string();
let views = list(&org).await;
assert_eq!(views.len(), 1);
assert_eq!(views[0]["view_type"], "traces");
let (status, updated) = call(
"PUT",
&format!("/{org}/savedviews/{view_id}"),
Some(json!({"data": {"v": 2}, "view_name": "checkout errors", "view_type": "logs"})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(updated["view_type"], "traces");
assert_eq!(list(&org).await[0]["view_type"], "traces");
}
#[tokio::test]
async fn test_create_view_rejects_unknown_type() {
infra::db::create_table().await.unwrap();
let org = new_org();
let (status, _) = create(&org, "bad", Some("metrics")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(list(&org).await.is_empty());
}
#[tokio::test]
async fn test_view_names_are_unique_per_type() {
infra::db::create_table().await.unwrap();
let org = new_org();
assert_eq!(create(&org, "dup", None).await.0, StatusCode::OK);
assert_eq!(create(&org, "DUP", Some("traces")).await.0, StatusCode::OK);
assert_eq!(
create(&org, "Dup", Some("traces")).await.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
create(&org, "dup", Some("logs")).await.0,
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn test_update_traces_view_to_a_logs_view_name_succeeds() {
infra::db::create_table().await.unwrap();
let org = new_org();
assert_eq!(create(&org, "alpha", None).await.0, StatusCode::OK);
let (status, created) = create(&org, "beta", Some("traces")).await;
assert_eq!(status, StatusCode::OK);
let view_id = created["view_id"].as_str().unwrap();
let (status, _) = call(
"PUT",
&format!("/{org}/savedviews/{view_id}"),
Some(json!({"data": {}, "view_name": "alpha"})),
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_an_org_sharing_a_name_prefix_keeps_its_views_separate() {
infra::db::create_table().await.unwrap();
let org = new_org();
let longer_org = format!("{org}b");
assert_eq!(create(&longer_org, "shared", None).await.0, StatusCode::OK);
assert!(list(&org).await.is_empty());
assert_eq!(create(&org, "shared", None).await.0, StatusCode::OK);
}
}
+24
View File
@@ -506,6 +506,8 @@ pub struct OrganizationSettingPayload {
pub max_series_per_query: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_stream_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub red_insights_enabled: Option<bool>,
#[cfg(feature = "enterprise")]
#[serde(skip_serializing_if = "Option::is_none")]
pub claim_parser_function: Option<String>,
@@ -545,6 +547,8 @@ pub struct OrganizationSetting {
pub max_series_per_query: Option<usize>,
#[serde(default = "default_usage_stream_enabled")]
pub usage_stream_enabled: bool,
#[serde(default)]
pub red_insights_enabled: bool,
#[cfg(feature = "enterprise")]
#[serde(default = "default_claim_parser_function")]
pub claim_parser_function: String,
@@ -584,6 +588,7 @@ impl Default for OrganizationSetting {
dark_mode_theme_color,
max_series_per_query: None,
usage_stream_enabled: default_usage_stream_enabled(),
red_insights_enabled: false,
#[cfg(feature = "enterprise")]
claim_parser_function: default_claim_parser_function(),
cross_links: Vec::new(),
@@ -1456,6 +1461,7 @@ mod tests {
dark_mode_theme_color: None,
max_series_per_query: None,
usage_stream_enabled: None,
red_insights_enabled: None,
#[cfg(feature = "enterprise")]
claim_parser_function: None,
cross_links: None,
@@ -1474,6 +1480,7 @@ mod tests {
assert!(!obj.contains_key("dark_mode_theme_color"));
assert!(!obj.contains_key("max_series_per_query"));
assert!(!obj.contains_key("usage_stream_enabled"));
assert!(!obj.contains_key("red_insights_enabled"));
assert!(!obj.contains_key("cross_links"));
}
@@ -1532,6 +1539,7 @@ mod tests {
dark_mode_theme_color: None,
max_series_per_query: None,
usage_stream_enabled: false,
red_insights_enabled: false,
#[cfg(feature = "enterprise")]
claim_parser_function: String::new(),
cross_links: vec![],
@@ -1562,6 +1570,7 @@ mod tests {
dark_mode_theme_color: Some("#000".to_string()),
max_series_per_query: Some(1000),
usage_stream_enabled: false,
red_insights_enabled: false,
#[cfg(feature = "enterprise")]
claim_parser_function: String::new(),
cross_links: vec![],
@@ -1638,4 +1647,19 @@ mod tests {
let parsed: OrganizationSetting = serde_json::from_str(json_false).unwrap();
assert!(!parsed.usage_stream_enabled);
}
#[test]
fn test_red_insights_enabled_defaults_off_and_round_trips() {
assert!(!OrganizationSetting::default().red_insights_enabled);
let legacy: OrganizationSetting =
serde_json::from_str(r#"{"scrape_interval": 15}"#).unwrap();
assert!(!legacy.red_insights_enabled);
let on: OrganizationSetting =
serde_json::from_str(r#"{"scrape_interval": 15, "red_insights_enabled": true}"#)
.unwrap();
assert!(on.red_insights_enabled);
let payload: OrganizationSettingPayload =
serde_json::from_str(r#"{"red_insights_enabled": true}"#).unwrap();
assert_eq!(payload.red_insights_enabled, Some(true));
}
}
+59
View File
@@ -25,6 +25,10 @@ pub struct CreateViewRequest {
/// User-readable name of the view, must be unique within the organization.
pub view_name: String,
/// View kind, `logs` or `traces`; absent means `logs`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_type: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -44,6 +48,8 @@ pub struct View {
pub data: serde_json::Value,
pub view_id: String,
pub view_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_type: Option<String>,
}
/// Save the bandwidth for a given view, without sending the actual data
@@ -53,6 +59,8 @@ pub struct ViewWithoutData {
pub org_id: String,
pub view_id: String,
pub view_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_type: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -86,6 +94,7 @@ mod tests {
let request = CreateViewRequest {
data: serde_json::json!({"key": "value", "number": 42}),
view_name: "test-view".to_string(),
view_type: None,
};
let serialized = serde_json::to_string(&request);
@@ -134,6 +143,7 @@ mod tests {
data: serde_json::json!({"dashboard": "main"}),
view_id: "view456".to_string(),
view_name: "main-dashboard".to_string(),
view_type: None,
};
let serialized = serde_json::to_string(&view);
@@ -159,6 +169,7 @@ mod tests {
org_id: "org123".to_string(),
view_id: "view456".to_string(),
view_name: "main-dashboard".to_string(),
view_type: None,
};
let serialized = serde_json::to_string(&view);
@@ -185,11 +196,13 @@ mod tests {
org_id: "org123".to_string(),
view_id: "view1".to_string(),
view_name: "dashboard1".to_string(),
view_type: None,
},
ViewWithoutData {
org_id: "org123".to_string(),
view_id: "view2".to_string(),
view_name: "dashboard2".to_string(),
view_type: None,
},
],
};
@@ -273,4 +286,50 @@ mod tests {
assert_eq!(deserialized.view_id, "view456");
assert_eq!(deserialized.view_name, "new-dashboard");
}
#[test]
fn test_view_type_round_trips_when_set() {
let request = CreateViewRequest {
data: serde_json::json!({}),
view_name: "v".to_string(),
view_type: Some("traces".to_string()),
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["view_type"], "traces");
let back: CreateViewRequest = serde_json::from_value(json).unwrap();
assert_eq!(back.view_type.as_deref(), Some("traces"));
let view = View {
org_id: "o".to_string(),
data: serde_json::json!({}),
view_id: "id".to_string(),
view_name: "v".to_string(),
view_type: Some("traces".to_string()),
};
let back: ViewWithoutData =
serde_json::from_str(&serde_json::to_string(&view).unwrap()).unwrap();
assert_eq!(back.view_type.as_deref(), Some("traces"));
}
#[test]
fn test_view_type_omitted_when_unset() {
let view = ViewWithoutData {
org_id: "o".to_string(),
view_id: "id".to_string(),
view_name: "v".to_string(),
view_type: None,
};
let json = serde_json::to_value(&view).unwrap();
assert!(json.get("view_type").is_none());
}
#[test]
fn test_legacy_json_without_view_type_deserialises_as_none() {
let legacy = r#"{"org_id":"o","data":{},"view_id":"id","view_name":"v"}"#;
let view: View = serde_json::from_str(legacy).unwrap();
assert!(view.view_type.is_none());
let request: CreateViewRequest =
serde_json::from_str(r#"{"data":{},"view_name":"v"}"#).unwrap();
assert!(request.view_type.is_none());
}
}
+56
View File
@@ -6483,4 +6483,60 @@ mod tests {
// An untagged request yields no role group at all, which is the bug this pins.
assert_ne!(RoleGroup::from(SearchEventType::UI), RoleGroup::Background);
}
#[test]
fn red_insights_templates_pass_create_validation() {
use crate::traces::red_insights::{RedSignal, StreamColumns, template};
for has_parent in [true, false] {
let cols = StreamColumns {
has_parent,
has_status: true,
has_duration: true,
};
for (stream, service) in [
("default", "checkout"),
("a\"b", "o'brien"),
("t/x", "api/v1"),
] {
for signal in RedSignal::ALL {
let req = template(stream, service, signal, &cols)
.expect("every signal is eligible here")
.into_request("default");
validate_config_request(&req)
.unwrap_or_else(|e| panic!("{signal:?} on {stream}/{service}: {e}"));
config::meta::alerts::tags::normalize_tags(&req.tags).unwrap();
}
}
}
}
#[test]
fn red_insights_skips_exactly_the_rate_detectors_g4_rejects() {
use crate::traces::red_insights::{RedSignal, StreamColumns, template};
let cols = StreamColumns {
has_parent: true,
has_status: true,
has_duration: true,
};
for word in ERROR_VOCABULARY {
let stream = format!("app-{word}");
assert!(
template(&stream, "svc", RedSignal::Rate, &cols).is_none(),
"{stream}"
);
let mut req = template("default", "svc", RedSignal::Rate, &cols)
.unwrap()
.into_request("default");
req.stream_name = stream.clone();
assert!(validate_config_request(&req).is_err(), "{stream}");
for signal in [RedSignal::ErrorRatio, RedSignal::P95Latency] {
let req = template(&stream, "svc", signal, &cols)
.unwrap()
.into_request("default");
validate_config_request(&req).unwrap_or_else(|e| panic!("{signal:?}: {e}"));
}
}
}
}
+35 -10
View File
@@ -33,10 +33,11 @@ use config::{
promql::HASH_LABEL,
self_reporting::usage::{RequestStats, RunOutcome, TriggerData, TriggerDataType},
stream::{PartitionTimeLevel, StreamParams, StreamPartition, StreamType},
triggers::ScheduledTriggerData,
},
utils::{
flatten,
json::*,
json::{self, *},
schema::format_partition_key,
time::{DAY_MICRO_SECS, HOUR_MICRO_SECS},
},
@@ -268,15 +269,7 @@ pub async fn evaluate_trigger(triggers: TriggerAlertData) {
// After the notification is sent successfully, we need to update
// the silence period of the trigger
if let Err(e) = db::scheduler::update_trigger(
db::scheduler::Trigger {
org: alert.org_id.to_string(),
module: db::scheduler::TriggerModule::Alert,
module_key,
is_silenced: true,
is_realtime: true,
next_run_at,
..Default::default()
},
silenced_realtime_trigger(&alert.org_id, module_key, next_run_at, now),
false,
"",
)
@@ -813,6 +806,29 @@ pub fn refactor_map(
new_map
}
fn silenced_realtime_trigger(
org: &str,
module_key: String,
next_run_at: i64,
fired_at: i64,
) -> db::scheduler::Trigger {
db::scheduler::Trigger {
org: org.to_string(),
module: db::scheduler::TriggerModule::Alert,
module_key,
is_silenced: true,
is_realtime: true,
next_run_at,
// Realtime rows carry no other data, and the wakeup clones it forward.
data: json::to_string(&ScheduledTriggerData {
last_satisfied_at: Some(fired_at),
..Default::default()
})
.unwrap_or_default(),
..Default::default()
}
}
/// The span of one write partition, which a record's time bucket is counted in.
fn partition_bucket_micros(time_level: PartitionTimeLevel) -> i64 {
match time_level {
@@ -830,6 +846,15 @@ mod tests {
use super::*;
#[test]
fn test_silenced_realtime_trigger_records_when_it_fired() {
let trigger = silenced_realtime_trigger("org1", "key1".to_string(), 2_000, 1_000);
assert!(trigger.is_silenced && trigger.is_realtime);
assert_eq!(trigger.next_run_at, 2_000);
let data: ScheduledTriggerData = json::from_str(&trigger.data).unwrap();
assert_eq!(data.last_satisfied_at, Some(1_000));
}
#[test]
fn test_format_partition_key() {
assert_eq!(format_partition_key("default/olympics"), "defaultolympics");
+1
View File
@@ -56,6 +56,7 @@ use serde_json::Map;
pub mod agent_signals;
pub mod inferred;
pub mod otel;
pub mod red_insights;
pub mod service_graph;
pub mod session;
#[cfg(test)]
File diff suppressed because it is too large Load Diff
+23
View File
@@ -169,6 +169,29 @@ pub async fn get_org_setting_usage_stream_enabled(org_id: &str) -> Result<bool,
Ok(usage_stream_enabled)
}
/// Whether automatic RED anomaly insights are on for an org; absent settings mean off.
pub async fn get_org_setting_red_insights_enabled(org_id: &str) -> Result<bool, Error> {
let key = format!("{ORG_SETTINGS_KEY_PREFIX}/{org_id}");
if let Some(v) = ORGANIZATION_SETTING.read().await.get(&key) {
return Ok(v.red_insights_enabled);
}
let settings: OrganizationSetting = match db::get(&key).await {
Ok(settings) => json::from_slice(&settings)?,
Err(Error::DbError(infra::errors::DbError::KeyNotExists(_))) => {
OrganizationSetting::default()
}
Err(e) => return Err(e),
};
let red_insights_enabled = settings.red_insights_enabled;
ORGANIZATION_SETTING
.write()
.await
.insert(key.to_string(), settings);
Ok(red_insights_enabled)
}
/// Cache the existing org settings in the beginning
pub async fn org_settings_cache() -> Result<(), anyhow::Error> {
let prefix = ORG_SETTINGS_KEY_PREFIX;
+40 -15
View File
@@ -22,9 +22,18 @@ use infra::errors::Error;
use crate as db;
pub const SAVED_VIEWS_KEY_PREFIX: &str = "/organization/savedviews";
const DEFAULT_VIEW_TYPE: &str = "logs";
const VIEW_TYPES: [&str; 2] = ["logs", "traces"];
pub async fn set_view(org_id: &str, view: &CreateViewRequest) -> Result<View, Error> {
if view_exists_with_name(org_id, &view.view_name)
let view_type = view.view_type.as_deref().unwrap_or(DEFAULT_VIEW_TYPE);
if !VIEW_TYPES.contains(&view_type) {
return Err(Error::Message(format!(
"Invalid view_type '{view_type}', expected one of: {}",
VIEW_TYPES.join(", ")
)));
}
if view_exists_with_name(org_id, &view.view_name, view_type)
.await
.is_some()
{
@@ -39,6 +48,7 @@ pub async fn set_view(org_id: &str, view: &CreateViewRequest) -> Result<View, Er
view_id: view_id.clone(),
data: view.data.clone(),
view_name: view.view_name.clone(),
view_type: view.view_type.clone(),
};
let key = format!("{SAVED_VIEWS_KEY_PREFIX}/{org_id}/{view_id}");
let val = json::to_vec(&view)
@@ -56,7 +66,12 @@ pub async fn update_view(
view_id: &str,
view: &UpdateViewRequest,
) -> Result<View, Error> {
if let Some(existing_id) = view_exists_with_name(org_id, &view.view_name).await
let original_view = get_view(org_id, view_id).await?;
let view_type = original_view
.view_type
.as_deref()
.unwrap_or(DEFAULT_VIEW_TYPE);
if let Some(existing_id) = view_exists_with_name(org_id, &view.view_name, view_type).await
&& existing_id != view_id
{
return Err(Error::Message(format!(
@@ -65,13 +80,10 @@ pub async fn update_view(
)));
}
let key = format!("{SAVED_VIEWS_KEY_PREFIX}/{org_id}/{view_id}");
let updated_view = match get_view(org_id, view_id).await {
Ok(original_view) => View {
data: view.data.clone(),
view_name: view.view_name.clone(),
..original_view
},
Err(e) => return Err(e),
let updated_view = View {
data: view.data.clone(),
view_name: view.view_name.clone(),
..original_view
};
let val = json::to_vec(&updated_view)
.map_err(|e| Error::Message(format!("Failed to serialize saved view: {e}")))?;
@@ -94,8 +106,7 @@ pub async fn get_view(org_id: &str, view_id: &str) -> Result<View, Error> {
/// Return all the saved views but query limited data only, associated with a
/// provided org_id This will not contain the payload.
pub async fn get_views_list_only(org_id: &str) -> Result<ViewsWithoutData, Error> {
let key = format!("{SAVED_VIEWS_KEY_PREFIX}/{org_id}");
let ret = db::list_values(&key).await?;
let ret = db::list_values(&list_prefix(org_id)).await?;
let mut views: Vec<ViewWithoutData> = ret
.iter()
.filter_map(|view| json::from_slice(view).ok())
@@ -114,17 +125,23 @@ pub async fn delete_view(org_id: &str, view_id: &str) -> Result<(), Error> {
Ok(())
}
/// Check if a saved view with the given name already exists in the org.
/// Returns `Some(view_id)` if found, `None` otherwise.
async fn view_exists_with_name(org_id: &str, view_name: &str) -> Option<String> {
async fn view_exists_with_name(org_id: &str, view_name: &str, view_type: &str) -> Option<String> {
let views = get_views_list_only(org_id).await.ok()?;
views
.views
.iter()
.find(|v| v.view_name.eq_ignore_ascii_case(view_name))
.find(|v| {
v.view_type.as_deref().unwrap_or(DEFAULT_VIEW_TYPE) == view_type
&& v.view_name.eq_ignore_ascii_case(view_name)
})
.map(|v| v.view_id.clone())
}
// Defensive trailing `/`: a pure prefix-scanning store would otherwise match org `ab` for `a`.
fn list_prefix(org_id: &str) -> String {
format!("{SAVED_VIEWS_KEY_PREFIX}/{org_id}/")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -138,4 +155,12 @@ mod tests {
fn test_saved_views_key_prefix_starts_with_slash() {
assert!(SAVED_VIEWS_KEY_PREFIX.starts_with('/'));
}
#[test]
fn test_list_prefix_does_not_match_an_org_sharing_its_prefix() {
let own_key = format!("{SAVED_VIEWS_KEY_PREFIX}/a/view1");
let other_key = format!("{SAVED_VIEWS_KEY_PREFIX}/ab/view1");
assert!(own_key.starts_with(&list_prefix("a")));
assert!(!other_key.starts_with(&list_prefix("a")));
}
}
+4
View File
@@ -65,6 +65,8 @@ mod pipeline_error_cleanup;
mod prompt_webhook_delivery;
mod promql;
mod promql_self_consume;
#[cfg(feature = "enterprise")]
mod red_insights;
mod scheduler;
#[cfg(feature = "enterprise")]
mod service_graph;
@@ -1099,6 +1101,8 @@ pub async fn init() -> Result<(), anyhow::Error> {
.disabled
{
tokio::task::spawn(anomaly_claim_supervisor());
// Here because create_config does not check the anomaly kill switch itself.
tokio::task::spawn(red_insights::run());
}
// Every node that serves writes publishes them, not only the scheduler.
openobserve_synthetics::service::start_publish_queue();
+328
View File
@@ -0,0 +1,328 @@
// 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 std::{collections::BTreeMap, time::Duration};
use config::meta::{
folder::{Folder, FolderType},
search::{Query, Request, RequestEncoding, SearchEventType},
stream::StreamType,
};
use db::folders::FolderError;
use openobserve_core::{
anomaly_detection,
traces::red_insights::{
self, FOLDER_NAME, MANAGED_TAG, MAX_CREATES, MAX_SERVICES, MIN_REQUESTS_24H,
ManagedDetector, ServiceVolume, StreamRead,
},
};
const CYCLE: Duration = Duration::from_secs(6 * 60 * 60);
/// Lets the job-cluster election resolve before the first claim check.
const FIRST_RUN_DELAY: Duration = Duration::from_secs(5 * 60);
const VOLUME_WINDOW_US: i64 = 24 * 60 * 60 * 1_000_000;
pub async fn run() {
let start = tokio::time::Instant::now() + FIRST_RUN_DELAY;
let mut ticker = tokio::time::interval_at(start, CYCLE);
loop {
ticker.tick().await;
// The per-org dist lock is regional, so only the region holding the claim reconciles.
if !super::anomaly_holds_job_cluster_claim().await {
continue;
}
// The per-org lock waits, not skips, so each scheduler would re-reconcile in turn.
if !super::leader::is_alert_manager_leader().await {
continue;
}
let orgs = match db::organization::list(None).await {
Ok(orgs) => orgs,
Err(e) => {
log::error!("[RED insights] could not list orgs: {e}");
continue;
}
};
for org in orgs {
if let Err(e) = reconcile_if_needed(&org.identifier).await {
log::error!("[RED insights] org {}: {e}", org.identifier);
}
}
}
}
async fn reconcile_if_needed(org_id: &str) -> anyhow::Result<()> {
let prefetched = if db::organization::get_org_setting_red_insights_enabled(org_id).await? {
None
} else {
let configs = managed_configs(org_id).await?;
if configs.is_empty() {
return Ok(());
}
Some(configs)
};
let locker = infra::dist_lock::lock(&format!("red_insights/{org_id}"), 0).await?;
let result = reconcile(org_id, prefetched).await;
if let Err(e) = infra::dist_lock::unlock(&locker).await {
log::warn!("[RED insights] org {org_id}: failed to release the lock: {e}");
}
result
}
async fn reconcile(org_id: &str, prefetched: Option<Vec<serde_json::Value>>) -> anyhow::Result<()> {
let configs = match prefetched {
Some(configs) => configs,
None => managed_configs(org_id).await?,
};
if !db::organization::get_org_setting_red_insights_enabled(org_id).await? {
for config in &configs {
delete(org_id, config_str(config, "anomaly_id")).await;
}
return Ok(());
}
let mut reads = read_streams(org_id).await;
let volumes = service_volumes(org_id, &mut reads).await;
let detectors: Vec<_> = configs.iter().filter_map(managed_detector).collect();
retire_removed_streams(org_id, &detectors, &mut reads).await;
let existing = red_insights::on_read_streams(detectors, &reads);
// Uncapped here: a signal the template skips must not use up the per-cycle create budget.
let plan = red_insights::plan(
true,
&volumes,
&existing,
MAX_SERVICES,
MIN_REQUESTS_24H,
usize::MAX,
);
for id in &plan.delete {
delete(org_id, id).await;
}
let templates: Vec<_> = plan
.create
.iter()
.filter_map(|(stream, service, signal)| match reads.get(stream)? {
StreamRead::Read(cols) => red_insights::template(stream, service, *signal, cols),
_ => None,
})
.take(MAX_CREATES)
.collect();
if templates.is_empty() {
return Ok(());
}
let folder_id = red_folder(org_id, &configs).await?;
for template in templates {
let name = template.name.clone();
if let Err(e) =
anomaly_detection::create_config(org_id, template.into_request(&folder_id)).await
{
log::error!("[RED insights] org {org_id}: failed to create '{name}': {e}");
}
}
Ok(())
}
async fn managed_configs(org_id: &str) -> anyhow::Result<Vec<serde_json::Value>> {
let configs = anomaly_detection::list_configs(org_id, None, None).await?;
Ok(configs
.into_iter()
.filter(|c| config_tags(c).iter().any(|t| t == MANAGED_TAG))
.collect())
}
fn config_tags(config: &serde_json::Value) -> Vec<String> {
config
.get("tags")
.and_then(|v| v.as_array())
.map(|tags| {
tags.iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn config_str<'a>(config: &'a serde_json::Value, key: &str) -> &'a str {
config.get(key).and_then(|v| v.as_str()).unwrap_or_default()
}
/// An unparseable managed detector maps to `None`, so the plan leaves it alone.
fn managed_detector(config: &serde_json::Value) -> Option<ManagedDetector> {
let stream = config_str(config, "stream_name");
let (service, signal, current) = red_insights::parse_managed(
&config_tags(config),
config_str(config, "custom_sql"),
stream,
)?;
Some(ManagedDetector {
id: config_str(config, "anomaly_id").to_string(),
stream: stream.to_string(),
service,
signal,
enabled: config
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true),
current,
})
}
async fn delete(org_id: &str, anomaly_id: &str) {
if let Err(e) = anomaly_detection::delete_config(org_id, anomaly_id).await {
log::error!("[RED insights] org {org_id}: failed to delete {anomaly_id}: {e}");
}
}
/// The public folder id, which is what `create_config`'s `folder_id` takes.
async fn red_folder(org_id: &str, configs: &[serde_json::Value]) -> anyhow::Result<String> {
if let Some(folder_id) = configs
.iter()
.map(|c| config_str(c, "folder_id"))
.find(|id| !id.is_empty())
{
return Ok(folder_id.to_string());
}
match db::folders::get_folder_by_name(org_id, FOLDER_NAME, FolderType::Alerts).await {
Ok(folder) => return Ok(folder.folder_id),
Err(FolderError::NotFound) => {}
Err(e) => return Err(e.into()),
}
let folder = Folder {
folder_id: String::new(),
name: FOLDER_NAME.to_string(),
description: "Anomaly detectors managed by RED insights".to_string(),
icon: None,
};
match db::folders::save_folder(org_id, folder, FolderType::Alerts, false).await {
Ok(folder) => Ok(folder.folder_id),
Err(FolderError::FolderNameAlreadyExists) => {
Ok(
db::folders::get_folder_by_name(org_id, FOLDER_NAME, FolderType::Alerts)
.await?
.folder_id,
)
}
Err(e) => Err(e.into()),
}
}
async fn read_streams(org_id: &str) -> BTreeMap<String, StreamRead> {
let mut reads = BTreeMap::new();
for stream in db::schema::list_streams_from_cache(org_id, StreamType::Traces).await {
let read = match infra::schema::get(org_id, &stream, StreamType::Traces).await {
Ok(schema) => red_insights::read_stream(|name| schema.field_with_name(name).is_ok()),
Err(e) => {
log::warn!("[RED insights] org {org_id}: schema read for {stream} failed: {e}");
StreamRead::Unread
}
};
if read == StreamRead::Retired {
log::info!("[RED insights] org {org_id}: {stream} has no service_name/span_kind");
}
reads.insert(stream, read);
}
reads
}
/// A lookup error leaves the stream unread, so a transient failure never deletes its detectors.
async fn retire_removed_streams(
org_id: &str,
detectors: &[ManagedDetector],
reads: &mut BTreeMap<String, StreamRead>,
) {
let unlisted: Vec<String> = detectors
.iter()
.filter(|d| !reads.contains_key(&d.stream))
.map(|d| d.stream.clone())
.collect();
for stream in unlisted {
match infra::schema::get(org_id, &stream, StreamType::Traces).await {
// A missing stream reads back as an empty schema, never as an error.
Ok(schema) if schema.fields().is_empty() => {
reads.insert(stream, StreamRead::Retired);
}
Ok(_) => {}
Err(e) => {
log::warn!("[RED insights] org {org_id}: schema read for {stream} failed: {e}")
}
}
}
}
/// A failed or partial search marks its stream unread: zero traffic would cost it its detectors.
async fn service_volumes(
org_id: &str,
reads: &mut BTreeMap<String, StreamRead>,
) -> Vec<ServiceVolume> {
let end_time = config::utils::time::now_micros();
let mut volumes = Vec::new();
for (stream, read) in reads.iter_mut() {
let StreamRead::Read(cols) = *read else {
continue;
};
let req = Request {
query: Query {
sql: red_insights::volume_sql(stream, &cols),
from: 0,
size: (MAX_SERVICES * 2) as i64,
start_time: end_time - VOLUME_WINDOW_US,
end_time,
track_total_hits: false,
uses_zo_fn: false,
query_fn: None,
..Default::default()
},
encoding: RequestEncoding::Empty,
regions: vec![],
clusters: vec![],
timeout: 0,
// Background nodes, as for anomaly training, so this never lands on interactive ones.
search_type: Some(SearchEventType::DerivedStream),
search_event_context: None,
use_cache: false,
clear_cache: false,
local_mode: None,
agent_options: None,
};
let trace_id = config::ider::generate_trace_id();
let resp = match search_service::search(&trace_id, org_id, StreamType::Traces, None, &req)
.await
{
Ok(resp) if !resp.is_partial => resp,
Ok(_) => {
log::warn!("[RED insights] org {org_id}: volume search on {stream} was partial");
*read = StreamRead::Unread;
continue;
}
Err(e) => {
log::warn!("[RED insights] org {org_id}: volume search on {stream} failed: {e}");
*read = StreamRead::Unread;
continue;
}
};
volumes.extend(resp.hits.iter().filter_map(|hit| {
let service = hit
.get("service_name")?
.as_str()
.filter(|s| !s.is_empty())?;
Some(ServiceVolume {
stream: stream.clone(),
service: service.to_string(),
requests_24h: hit.get("requests")?.as_u64()?,
})
}));
}
volumes
}
@@ -17,7 +17,7 @@ use std::{fmt::Debug, sync::Arc};
use arrow::datatypes::SchemaRef;
use datafusion::{
common::{Result, tree_node::TreeNodeRecursion},
common::{Result, internal_err, tree_node::TreeNodeRecursion},
execution::{SendableRecordBatchStream, TaskContext},
physical_expr::{EquivalenceProperties, PhysicalExpr},
physical_plan::{
@@ -42,37 +42,36 @@ pub struct AggregateTopkExec {
}
impl AggregateTopkExec {
/// Create a new AggregateMergeExec with explicit cache strategy
pub fn new(
/// Create a new AggregateTopkExec; errors when the input has no field for `sort_field`.
pub fn try_new(
input: Arc<dyn ExecutionPlan>,
sort_field: &str,
descending: bool,
limit: u64,
) -> Self {
) -> Result<Self> {
// Partial or no cache: cached partitions + input partitions
let target_partitions = input.output_partitioning().partition_count();
let cache = Self::compute_properties(Arc::clone(&input.schema()), target_partitions);
let sort_field = input
.schema()
.fields()
.iter()
.find(|f| {
// field name like count(*)[count]
f.name() == sort_field
|| f.name().split('[').next().is_some_and(|v| v == sort_field)
})
.unwrap()
.name()
.to_string();
let schema = input.schema();
let Some(field) = schema.fields().iter().find(|f| {
// State fields are `<name>[count]`; the name itself may hold `[`, e.g. an IN list.
f.name() == sort_field
|| f.name()
.rsplit_once('[')
.is_some_and(|(name, _)| name == sort_field)
}) else {
return internal_err!("AggregateTopkExec: no input field for {sort_field}");
};
let sort_field = field.name().to_string();
Self {
Ok(Self {
input,
cache,
target_partitions,
sort_field,
descending,
limit,
}
})
}
fn output_partitioning_helper(n_partitions: usize) -> Partitioning {
@@ -154,12 +153,12 @@ impl ExecutionPlan for AggregateTopkExec {
if children.is_empty() {
return Ok(self);
}
Ok(Arc::new(Self::new(
Ok(Arc::new(Self::try_new(
children[0].clone(),
&self.sort_field,
self.descending,
self.limit,
)))
)?))
}
fn execute(
@@ -31,12 +31,12 @@ pub fn try_decode(
inputs: &[Arc<dyn ExecutionPlan>],
_registry: &dyn FunctionRegistry,
) -> Result<Arc<dyn ExecutionPlan>> {
Ok(Arc::new(AggregateTopkExec::new(
Ok(Arc::new(AggregateTopkExec::try_new(
inputs[0].clone(),
&node.sort_field,
node.descending,
node.limit,
)))
)?))
}
pub fn try_encode(node: Arc<dyn ExecutionPlan>, buf: &mut Vec<u8>) -> Result<()> {
@@ -105,12 +105,12 @@ mod tests {
Arc::new(EmptyExec::new(Arc::clone(&schema))),
Arc::clone(&schema),
)?;
let plan: Arc<dyn ExecutionPlan> = Arc::new(AggregateTopkExec::new(
let plan: Arc<dyn ExecutionPlan> = Arc::new(AggregateTopkExec::try_new(
Arc::new(agg_plan) as Arc<dyn ExecutionPlan>,
"COUNT(1)",
false,
10,
));
)?);
// encode
let proto = super::super::get_physical_extension_codec();
@@ -73,7 +73,13 @@ impl PhysicalOptimizerRule for AggregateTopkRule {
}
if let Some(expr) = final_agg_plan.aggr_expr().first() {
if !["count", "avg", "min", "max", "sum"].contains(&expr.fun().name()) {
let eligible = match expr.fun().name() {
"min" | "max" => true,
// A DISTINCT count/sum partial state is a value list, not the final value.
"count" | "sum" => !expr.is_distinct(),
_ => false,
};
if !eligible {
return Ok(plan);
}
let expr_name = expr.name();
@@ -133,8 +139,11 @@ impl TreeNodeRewriter for AggregateTopkRewriter {
}
let input_plan = Arc::clone(agg_node);
let agg_plan =
AggregateTopkExec::new(input_plan, &self.field, self.descending, self.limit);
let Ok(agg_plan) =
AggregateTopkExec::try_new(input_plan, &self.field, self.descending, self.limit)
else {
return Ok(Transformed::no(node));
};
let node = node.replace_children(
vec![Arc::new(agg_plan) as Arc<dyn ExecutionPlan>],
@@ -230,8 +239,16 @@ impl<'n> TreeNodeVisitor<'n> for SortLimitVisitor {
#[cfg(test)]
mod tests {
use arrow::{
array::{Array, Int64Array, RecordBatch, StringArray},
compute::cast,
};
use arrow_schema::{DataType, Field, Schema};
use datafusion::{physical_plan::displayable, prelude::SessionConfig};
use datafusion::{
datasource::MemTable,
physical_plan::{collect, displayable},
prelude::SessionConfig,
};
use super::*;
use crate::datafusion::table_provider::empty_table::NewEmptyTable;
@@ -248,6 +265,10 @@ mod tests {
/// Whether the rule inserts `AggregateTopkExec` for `agg(v)` grouped by `name`, sorted
/// on the aggregate and limited.
async fn inserts_topk(agg: &str) -> bool {
inserts_topk_for(&format!("{agg}(v)")).await
}
async fn inserts_topk_for(select_expr: &str) -> bool {
enable_topk();
let schema = Arc::new(Schema::new(vec![
Field::new("name", DataType::Utf8, false),
@@ -258,8 +279,9 @@ mod tests {
);
ctx.register_table("t", Arc::new(NewEmptyTable::new("t", schema)))
.unwrap();
let sql =
format!("SELECT name, {agg}(v) AS x FROM t GROUP BY name ORDER BY x DESC LIMIT 10");
let sql = format!(
"SELECT name, {select_expr} AS x FROM t GROUP BY name ORDER BY x DESC LIMIT 10"
);
let plan = ctx
.sql(&sql)
.await
@@ -278,11 +300,17 @@ mod tests {
#[tokio::test]
async fn test_topk_keeps_the_supported_aggregates() {
for agg in ["count", "avg", "min", "max", "sum"] {
for agg in ["count", "min", "max", "sum"] {
assert!(inserts_topk(agg).await, "{agg} must stay eligible");
}
}
#[tokio::test]
async fn test_topk_matches_an_aggregate_name_with_brackets() {
let filtered = "COUNT(*) FILTER (WHERE CAST(v AS VARCHAR) IN ('2','5'))";
assert!(inserts_topk_for(filtered).await, "{filtered}");
}
#[tokio::test]
async fn test_topk_skips_approx_distinct() {
assert!(
@@ -290,4 +318,141 @@ mod tests {
"approx_distinct must stay on the regular aggregation path"
);
}
#[tokio::test]
async fn test_topk_skips_avg() {
assert!(
!inserts_topk("avg").await,
"avg must stay on the regular aggregation path"
);
}
#[tokio::test]
async fn test_topk_skips_distinct_with_filter() {
for expr in [
"COUNT(DISTINCT v) FILTER (WHERE v > 0)",
"SUM(DISTINCT v) FILTER (WHERE v > 0)",
] {
assert!(
!inserts_topk_for(expr).await,
"{expr} must stay on the regular aggregation path"
);
}
}
#[tokio::test]
async fn test_topk_keeps_min_max_distinct_with_filter() {
for expr in [
"MIN(DISTINCT v) FILTER (WHERE v > 0)",
"MAX(DISTINCT v) FILTER (WHERE v > 0)",
] {
assert!(inserts_topk_for(expr).await, "{expr} must stay eligible");
}
}
#[tokio::test]
async fn test_avg_top_group_with_few_rows_is_not_dropped() {
enable_topk();
// More groups than the heap keeps (1000), each outranking `top` by row count.
let mut names = vec!["top".to_string()];
let mut values = vec![100_i64];
for i in 0..1000 {
names.extend([format!("g{i}"), format!("g{i}")]);
values.extend([1, 1]);
}
let schema = Arc::new(Schema::new(vec![
Field::new("name", DataType::Utf8, false),
Field::new("v", DataType::Int64, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(names)),
Arc::new(Int64Array::from(values)),
],
)
.unwrap();
let ctx = datafusion::prelude::SessionContext::new_with_config(
SessionConfig::new().with_target_partitions(2),
);
ctx.register_table(
"t",
Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
)
.unwrap();
let plan = ctx
.sql("SELECT name, avg(v) AS x FROM t GROUP BY name ORDER BY x DESC LIMIT 1")
.await
.unwrap()
.create_physical_plan()
.await
.unwrap();
let plan = AggregateTopkRule::new(1)
.optimize(plan, &ConfigOptions::default())
.unwrap();
let batches = collect(plan, ctx.task_ctx()).await.unwrap();
let names: Vec<String> = batches
.iter()
.flat_map(|b| {
let col = cast(b.column(0), &DataType::Utf8).unwrap();
let col = col.as_any().downcast_ref::<StringArray>().unwrap();
col.iter().flatten().map(String::from).collect::<Vec<_>>()
})
.collect();
assert_eq!(names, vec!["top".to_string()]);
}
#[tokio::test]
async fn test_count_distinct_filter_top_group_is_not_dropped() {
enable_topk();
// Partial state is a value list: `[5]` outranks `[1, 2, 3]` despite a lower count.
let mut names = vec!["top".to_string(); 3];
let mut values = vec![1_i64, 2, 3];
for i in 0..1000 {
names.extend([format!("g{i}"), format!("g{i}")]);
values.extend([5, 5]);
}
let schema = Arc::new(Schema::new(vec![
Field::new("name", DataType::Utf8, false),
Field::new("v", DataType::Int64, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(StringArray::from(names)),
Arc::new(Int64Array::from(values)),
],
)
.unwrap();
let ctx = datafusion::prelude::SessionContext::new_with_config(
SessionConfig::new().with_target_partitions(2),
);
ctx.register_table(
"t",
Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
)
.unwrap();
let sql = "SELECT name, COUNT(DISTINCT v) FILTER (WHERE v > 0) AS x FROM t \
GROUP BY name ORDER BY x DESC LIMIT 1";
let plan = ctx
.sql(sql)
.await
.unwrap()
.create_physical_plan()
.await
.unwrap();
let plan = AggregateTopkRule::new(1)
.optimize(plan, &ConfigOptions::default())
.unwrap();
let batches = collect(plan, ctx.task_ctx()).await.unwrap();
let names: Vec<String> = batches
.iter()
.flat_map(|b| {
let col = cast(b.column(0), &DataType::Utf8).unwrap();
let col = col.as_any().downcast_ref::<StringArray>().unwrap();
col.iter().flatten().map(String::from).collect::<Vec<_>>()
})
.collect();
assert_eq!(names, vec!["top".to_string()]);
}
}
@@ -110,20 +110,12 @@ export class TracesPage {
this.serviceGraphPage = '[data-test="service-graph-page"]';
// ===== ANALYZE DIMENSIONS SELECTORS (VERIFIED against Vue source) =====
// TracesMetricsDashboard.vue: data-test="insights-button"
this.insightsButton = '[data-test="insights-button"]';
// SearchResult.vue: error-count badge doubles as the error-only toggle
this.errorOnlyToggle = '[data-test="traces-error-count-badge"]';
// Traces SearchBar.vue: data-test="traces-search-bar-show-metrics-toggle-btn"
this.metricsToggle = '[data-test="traces-search-bar-show-metrics-toggle-btn"]';
// TracesAnalysisDashboard.vue was migrated to ODrawer
// (data-test="traces-analysis-dashboard-drawer"). The legacy
// `analysis-dashboard-close` data-test and `.analysis-dashboard-card`
// template class were removed — `.analysis-dashboard-card` only survives
// in CSS rules now, no element actually carries the class. Scope all
// selectors via the ODrawer slug instead.
this.analysisDashboardDrawer = '[data-test="traces-analysis-dashboard-drawer"]';
this.analysisDashboardClose = '[data-test="traces-analysis-dashboard-drawer"] [data-test="o-drawer-close-btn"]';
this.analysisDashboardClose = '[data-test="traces-analysis-dashboard-drawer"] [data-test="traces-drill-down-back-btn"]';
// TracesAnalysisDashboard.vue: dimension sidebar (visible by default, not a dialog)
this.dimensionSelectorSidebar = '[data-test="dimension-selector-sidebar"]';
this.dimensionSelectorCollapseBtn = '[data-test="dimension-selector-collapse-btn"]';
@@ -471,7 +463,9 @@ export class TracesPage {
}
async toggleMetricsDashboard() {
await this.getMoreMenuButton().click();
await this.page.locator(this.showMetricsToggle).click();
await this.page.keyboard.press('Escape');
}
async switchToServiceMaps() {
@@ -2016,12 +2010,11 @@ export class TracesPage {
// ===== ANALYZE DIMENSIONS POM METHODS =====
// Selectors verified against actual Vue source code
// --- Insights Button (TracesMetricsDashboard.vue) ---
// --- Drill down button (Traces SearchBar.vue) ---
/**
* Check if Insights button is visible.
* The Insights button is ALWAYS visible when metrics dashboard shows
* (does NOT require brush selection).
* Check if the Drill down button is visible.
* It shows in Spans/Traces mode once a search is applied without error.
* @returns {Promise<boolean>}
*/
async isInsightsButtonVisible() {
@@ -2029,7 +2022,7 @@ export class TracesPage {
}
/**
* Click Insights button to open the Analysis Dashboard
* Click the Drill down button to open the Analysis Dashboard
*/
async clickInsightsButton() {
await this.page.locator(this.insightsButton).click();
@@ -2047,7 +2040,7 @@ export class TracesPage {
}
/**
* Close Analysis Dashboard via close button
* Close the Drill down view via its Back button
*/
async closeAnalysisDashboard() {
const closeBtn = this.page.locator(this.analysisDashboardClose);
+109
View File
@@ -1850,3 +1850,112 @@ describe("AlertList - trigger", () => {
]);
});
});
describe("AlertList - triggered in the last 15 minutes", () => {
const nowUs = () => Date.now() * 1000;
const loadRows = async () => {
const ranUs = nowUs() - 60_000_000;
const oldUs = nowUs() - 3 * 60 * 60 * 1_000_000;
const scheduled = { is_real_time: false };
alertsDB = [
makeAlert(1, { ...scheduled, name: "ran-not-fired", last_triggered_at: ranUs }),
makeAlert(2, {
...scheduled,
name: "ran-and-fired",
last_triggered_at: ranUs,
last_satisfied_at: ranUs,
}),
makeAlert(3, {
...scheduled,
name: "ran-fired-long-ago",
last_triggered_at: ranUs,
last_satisfied_at: oldUs,
}),
{
...makeAlert(4, { ...scheduled, name: "anomaly-ran-not-fired", last_triggered_at: ranUs }),
alert_type: "anomaly_detection",
last_satisfied_at: null,
} as any,
{
...makeAlert(5, {
...scheduled,
name: "anomaly-fired",
last_triggered_at: ranUs,
last_satisfied_at: ranUs,
}),
alert_type: "anomaly_detection",
} as any,
{
...makeAlert(6, {
...scheduled,
name: "composite-ran-not-fired",
last_triggered_at: ranUs,
}),
alert_type: "composite",
} as any,
{
...makeAlert(7, {
...scheduled,
name: "composite-fired",
last_triggered_at: ranUs,
last_satisfied_at: ranUs,
}),
alert_type: "composite",
} as any,
makeAlert(8, {
is_real_time: true,
name: "realtime-fired",
last_triggered_at: ranUs,
last_satisfied_at: ranUs,
}),
makeAlert(9, {
is_real_time: true,
name: "realtime-fired-long-ago",
last_triggered_at: ranUs,
last_satisfied_at: oldUs,
}),
makeAlert(10, {
is_real_time: true,
name: "realtime-woke-not-fired",
last_triggered_at: ranUs,
}),
];
const wrapper: any = await mountAlertList();
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 80));
await flushPromises();
return wrapper;
};
it("counts only alerts that fired recently, not ones that merely ran", async () => {
const wrapper = await loadRows();
expect(wrapper.vm.filteredResults).toHaveLength(10);
expect(wrapper.vm.stateCounts.recent).toBe(4);
});
it("filters the recent state down to alerts that fired recently", async () => {
const wrapper = await loadRows();
wrapper.vm.onStatSelect("recent");
await flushPromises();
expect(wrapper.vm.displayedAlerts.map((r: any) => r.name).sort()).toEqual([
"anomaly-fired",
"composite-fired",
"ran-and-fired",
"realtime-fired",
]);
});
it("marks the Last Satisfied time of a recent firing, not a Never cell", async () => {
const wrapper = await loadRows();
// A stale ?action=update from an earlier spec's navigation can swap the table for the editor.
await wrapper.vm.handleActionQuery(undefined);
await flushPromises();
const cells = wrapper.findAll('[data-test="o2-table-cell-last_satisfied_at"]');
const never = cells.filter((c: any) => c.text().includes("Never"));
const fired = cells.filter((c: any) => !c.text().includes("Never"));
expect(never.length).toBeGreaterThan(0);
expect(never.every((c: any) => !c.find(".rounded-full").exists())).toBe(true);
expect(fired.filter((c: any) => c.find(".bg-warning-500").exists())).toHaveLength(4);
});
});
+21 -24
View File
@@ -339,36 +339,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<template #cell-last_triggered_at="{ row }">
<OTimeCell
:value="row.last_triggered_at_raw"
unit="us"
mode="relative"
:timezone="store.state.timezone"
:empty-label="t('alerts.anomaly.retrainNever')"
/>
</template>
<template #cell-last_satisfied_at="{ row }">
<span class="inline-flex min-w-0 items-center gap-1.5">
<span
v-if="['hot', 'warm'].includes(recencyLevel(row.last_triggered_at_raw))"
v-if="['hot', 'warm'].includes(recencyLevel(row.last_satisfied_at_raw))"
class="h-1.5 w-1.5 shrink-0 rounded-full"
:class="
recencyLevel(row.last_triggered_at_raw) === 'hot'
recencyLevel(row.last_satisfied_at_raw) === 'hot'
? 'bg-warning-500 motion-safe:animate-pulse'
: 'bg-text-muted'
"
/>
<OTimeCell
:value="row.last_triggered_at_raw"
unit="us"
mode="relative"
:value="row.last_satisfied_at"
unit="iso"
mode="absolute"
:timezone="store.state.timezone"
:empty-label="t('alerts.anomaly.retrainNever')"
/>
</span>
</template>
<template #cell-last_satisfied_at="{ row }">
<OTimeCell
:value="row.last_satisfied_at"
unit="iso"
mode="absolute"
:timezone="store.state.timezone"
:empty-label="t('alerts.anomaly.retrainNever')"
/>
</template>
<template #cell-status="{ row }">
<span v-if="row.status && row.status !== '--'" class="relative inline-flex">
<OTag type="alertStatus" :value="row.status" />
@@ -1242,9 +1242,7 @@ export default defineComponent({
const filteredResults: Ref<any[]> = ref([]);
// ── "Calm Signal" table helpers ─────────────────────────────────────────
// Recency of the last trigger, bucketed for the trigger-time dot + the
// recently-fired row highlight. Derived from the RAW microsecond timestamp
// (last_triggered_at_raw) so it stays correct regardless of display timezone.
// Keyed on firing time, not run time; raw µs keeps it timezone-independent.
const RECENT_TRIGGER_MS = 15 * 60 * 1000; // "hot" — fired in the last 15 min
const RECENT_TRIGGER_DAY_MS = 24 * 60 * 60 * 1000; // "warm" — within a day
const triggerAgeMs = (rawMicros: unknown): number | null => {
@@ -1414,7 +1412,7 @@ export default defineComponent({
else paused += 1;
if (r.is_real_time === "anomaly" && String(r.status).toLowerCase() === "failed")
failed += 1;
if (recencyLevel(r.last_triggered_at_raw) === "hot") recent += 1;
if (recencyLevel(r.last_satisfied_at_raw) === "hot") recent += 1;
}
return { active, paused, failed, recent, total: rows.length };
});
@@ -1493,7 +1491,7 @@ export default defineComponent({
const f = stateFilter.value;
if (!f) return rows;
if (f === "recent")
return rows.filter((r: any) => recencyLevel(r.last_triggered_at_raw) === "hot");
return rows.filter((r: any) => recencyLevel(r.last_satisfied_at_raw) === "hot");
if (f === "failed")
return rows.filter(
(r: any) => r.is_real_time === "anomaly" && String(r.status).toLowerCase() === "failed",
@@ -1809,9 +1807,8 @@ export default defineComponent({
last_triggered_at: anomaly.last_triggered_at
? convertUnixToDateFormat(anomaly.last_triggered_at)
: "",
// Raw microsecond epoch — drives the relative-time cell, recency dot and
// recently-fired row highlight (timezone-independent).
last_triggered_at_raw: anomaly.last_triggered_at ?? null,
last_satisfied_at_raw: anomaly.last_satisfied_at ?? null,
last_satisfied_at: anomaly.last_satisfied_at
? convertUnixToDateFormat(anomaly.last_satisfied_at)
: "",
@@ -1945,6 +1942,7 @@ export default defineComponent({
is_real_time: "composite",
last_triggered_at: convertUnixToDateFormat(data.last_triggered_at),
last_triggered_at_raw: data.last_triggered_at ?? null,
last_satisfied_at_raw: data.last_satisfied_at ?? null,
last_satisfied_at: convertUnixToDateFormat(data.last_satisfied_at),
};
}
@@ -1972,9 +1970,8 @@ export default defineComponent({
frequency: data.is_real_time ? "" : frequency,
frequency_type: data?.trigger_condition?.frequency_type,
last_triggered_at: convertUnixToDateFormat(data.last_triggered_at),
// Raw microsecond epoch — drives the relative-time cell, recency dot
// and recently-fired row highlight (timezone-independent).
last_triggered_at_raw: data.last_triggered_at ?? null,
last_satisfied_at_raw: data.last_satisfied_at ?? null,
last_satisfied_at: convertUnixToDateFormat(data.last_satisfied_at),
last_trained_at: "",
status: "--",
@@ -137,6 +137,19 @@ describe("AnomalyDetectionChart", () => {
);
});
it("formats a traces duration percentile in time units", async () => {
const yLabel = async (alert: Record<string, any>) => {
wrapper?.unmount();
wrapper = await mountChart({ ...ANOMALY, stream_type: "traces", ...alert });
return metricOptions(wrapper).yAxis.axisLabel.formatter(938_661);
};
expect(await yLabel({ detection_function: "p95(duration)" })).toBe("939ms");
expect(await yLabel({ detection_function: "count(*)" })).toBe("939K");
expect(await yLabel({ query_mode: "custom_sql", detection_function: "p95(duration)" })).toBe(
"939K",
);
});
it("renders the band chart once the rows arrive", async () => {
wrapper = await mountChart();
expect(wrapper.find('[data-test="alerts-anomalydetectionchart-metric-chart"]').exists()).toBe(
@@ -237,6 +237,16 @@ const rangeOptions = computed(() => [
const interval = computed(() => props.alert?.histogram_interval);
// In custom SQL the function's field names a query alias, not a stream column.
const metricUnit = computed(() => {
const a = props.alert;
if (a?.stream_type !== "traces" || a?.query_mode === "custom_sql") return "numbers";
const match = /^(\w+)\((.+)\)$/.exec(String(a?.detection_function ?? ""));
return match?.[2] === "duration" && match[1].toLowerCase() !== "count"
? "microseconds"
: "numbers";
});
// Kind flags serialize only when true, so the stream schema is what says which per-kind split a query may reference.
const kindColumns = ref<AnomalyKindColumns>({ ...NO_KIND_COLUMNS });
// Charts wait for the schema probe, or the first render burns a query round-trip on the legacy shape.
@@ -409,6 +419,7 @@ const metricOptions = computed(() => {
},
{ startMs: windowUs.value.startUs / 1000, endMs: windowUs.value.endUs / 1000 },
anomalyIntervalMs(interval.value),
metricUnit.value,
);
});
@@ -31,6 +31,7 @@ export const makeOrganizationSettingsSchema = (t: (_key: string) => string) => {
// Non-validated form state (still form-owned via OFormSwitch).
toggleIngestionLogs: z.boolean().optional().default(false),
usageStreamEnabled: z.boolean().optional().default(false),
redInsightsEnabled: z.boolean().optional().default(false),
// CrossLinkManager is a composite custom control — kept bare and merged at
// submit; optional so it never blocks.
crossLinks: z.array(z.any()).optional(),
@@ -45,6 +45,7 @@ vi.mock("@/services/organizations", async (importOriginal) => {
});
});
import config from "@/aws-exports";
import organizations from "@/services/organizations";
const mockPostOrganizationSettings = organizations.post_organization_settings as any;
@@ -352,6 +353,68 @@ describe("OrganizationSettings", () => {
});
});
describe("RED insights setting", () => {
afterEach(() => {
config.isEnterprise = "false";
config.isCloud = "false";
});
it("hides the switch outside enterprise and does not post the field", async () => {
config.isEnterprise = "false";
config.isCloud = "false";
const wrapper = createWrapper();
expect(wrapper.find('[data-test="settings-red-insights"]').exists()).toBe(false);
await getForm(wrapper).vm.form.handleSubmit();
await flushPromises();
expect(mockPostOrganizationSettings.mock.calls[0][1]).not.toHaveProperty(
"red_insights_enabled",
);
});
it("shows the switch in enterprise, seeded from the store, and posts the field", async () => {
config.isEnterprise = "true";
config.isCloud = "false";
mockStore.state.organizationData.organizationSettings = {
trace_id_field_name: "trace_id",
span_id_field_name: "span_id",
red_insights_enabled: true,
};
const wrapper = createWrapper();
expect(wrapper.find('[data-test="settings-red-insights-btn"]').exists()).toBe(true);
const form = getForm(wrapper);
expect(form.vm.form.state.values.redInsightsEnabled).toBe(true);
form.vm.form.setFieldValue("redInsightsEnabled", false);
await form.vm.form.handleSubmit();
await flushPromises();
expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
"test-org-123",
expect.objectContaining({ red_insights_enabled: false }),
);
expect(mockStore.dispatch).toHaveBeenCalledWith(
"setOrganizationSettings",
expect.objectContaining({ red_insights_enabled: false }),
);
});
it("keeps the help text in a hover tooltip on the info icon, under a Traces heading", () => {
config.isEnterprise = "true";
config.isCloud = "false";
const wrapper = createWrapper();
const section = wrapper.find('[data-test="settings-red-insights"]');
const help = String(i18n.global.t("settings.redInsightsEnabledHelp"));
expect(section.find('[data-test="settings-red-insights-btn-info"]').exists()).toBe(true);
expect(section.text()).not.toContain(help);
expect(section.findComponent({ name: "OTooltip" }).props("content")).toBe(help);
expect(section.find('[data-test="settings-traces-heading"]').text()).toBe(
String(i18n.global.t("settings.tracesHeading")),
);
});
});
describe("Usage stream settings", () => {
it("should seed usageStreamEnabled from the store", () => {
mockStore.state.organizationData.organizationSettings.usage_stream_enabled = true;
@@ -66,6 +66,22 @@
/>
</div>
<div v-if="showRedInsights" data-test="settings-red-insights" class="o2-input">
<div data-test="settings-traces-heading" class="pt-4 pb-1 text-base font-bold">
{{ t("settings.tracesHeading") }}
</div>
<OFormSwitch
data-test="settings-red-insights-btn"
name="redInsightsEnabled"
:label="t('settings.redInsightsEnabledLabel')"
class="mt-2"
>
<template #tooltip>
<OTooltip :content="t('settings.redInsightsEnabledHelp')" />
</template>
</OFormSwitch>
</div>
<!-- Cross-Linking Configuration -->
<template v-if="store.state.zoConfig?.enable_cross_linking">
<OSeparator class="mt-6 mb-4" />
@@ -119,6 +135,7 @@ import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import config from "@/aws-exports";
import { toast } from "@/lib/feedback/Toast/useToast";
import analytics from "@/services/product_analytics";
@@ -145,6 +162,7 @@ const formDirty = ref(false);
// ignores the field outside cloud — so the section renders under both conditions.
const { isMetaOrg } = useIsMetaOrg();
const showDomainOrgMappings = computed(() => config.isCloud === "true" && isMetaOrg.value);
const showRedInsights = config.isEnterprise == "true";
const domainOrgMappings = ref<DomainOrgMapping[]>(
store.state?.organizationData?.organizationSettings?.domain_org_mappings || [],
);
@@ -158,6 +176,7 @@ const organizationSettingsDefaults = computed((): OrganizationSettingsForm => {
spanIdFieldName: s?.span_id_field_name ?? "",
toggleIngestionLogs: s?.toggle_ingestion_logs ?? false,
usageStreamEnabled: s?.usage_stream_enabled ?? false,
redInsightsEnabled: s?.red_insights_enabled ?? false,
};
});
@@ -197,6 +216,10 @@ const saveOrgSettings = async (value: OrganizationSettingsForm) => {
if (showDomainOrgMappings.value) {
payload.domain_org_mappings = domainOrgMappings.value;
}
// Same rule for the enterprise-only switch: a hidden field must not overwrite the stored one.
if (showRedInsights) {
payload.red_insights_enabled = value.redInsightsEnabled;
}
const crossLinksChanged =
JSON.stringify(crossLinks.value) !==
@@ -213,6 +236,7 @@ const saveOrgSettings = async (value: OrganizationSettingsForm) => {
cross_links: crossLinks.value,
usage_stream_enabled: value.usageStreamEnabled,
...(showDomainOrgMappings.value ? { domain_org_mappings: domainOrgMappings.value } : {}),
...(showRedInsights ? { red_insights_enabled: value.redInsightsEnabled } : {}),
};
store.dispatch("setOrganizationSettings", updatedSettings);
@@ -165,10 +165,12 @@ vi.mock("./useAnnotations", () => ({
// Enhanced HTTP Streaming mocking
let shouldStreamingThrow = false;
let streamOverride: ((payload: any, callbacks: any) => any) | null = null;
vi.mock("../useStreamingSearch", () => ({
default: () => ({
fetchQueryDataWithHttpStream: vi.fn().mockImplementation((payload, callbacks) => {
if (streamOverride) return streamOverride(payload, callbacks);
if (shouldStreamingThrow) {
callbacks?.error?.(payload, { message: "Streaming error" });
return "error-stream-id";
@@ -265,6 +267,7 @@ const resetAllMocks = () => {
// Reset streaming mocks
shouldStreamingThrow = false;
streamOverride = null;
// Reset cache mocks
mockCacheData = null;
@@ -1503,6 +1506,52 @@ describe("usePanelDataLoader", () => {
});
});
describe("superseded runs", () => {
it("a stream completing while the rerun is debounced writes nothing", async () => {
const streams: any[] = [];
streamOverride = (payload, callbacks) => {
streams.push({ payload, callbacks });
return "held-stream";
};
const loader = usePanelDataLoader(
createMockPanelSchema(),
createMockSelectedTimeObj({
start_time: new Date(Date.now() - 3600000),
end_time: new Date(),
}),
createMockVariablesData(),
ref(null),
ref(true),
ref("dashboards"),
ref("test-dashboard"),
ref("test-folder"),
ref(null),
ref(null),
ref(null),
ref(null),
ref(null),
ref(false),
);
await loader.loadData();
expect(streams).toHaveLength(1);
const cacheWritesBefore = cacheOperationCount;
const rerun = loader.loadData();
const [{ payload, callbacks }] = streams;
callbacks.data(payload, {
type: "search_response_hits",
content: { results: { hits: [{ value: "stale" }] } },
});
callbacks.data(payload, { type: "end" });
await callbacks.complete(payload, { type: "end" });
expect(JSON.stringify(loader.data.value)).not.toContain("stale");
expect(loader.loading.value).toBe(true);
expect(cacheOperationCount).toBe(cacheWritesBefore);
await rerun;
});
});
// ==== CACHE FUNCTIONALITY COVERAGE ====
describe("Cache Functionality", () => {
describe("restoreFromCache", () => {
@@ -365,6 +365,8 @@ export const usePanelDataLoader = (
abortController.abort();
}
invalidateRun();
// Create a new AbortController for the new operation
abortController = new AbortController();
// Checking if there are queries to execute
@@ -597,14 +599,19 @@ export const usePanelDataLoader = (
// Wire up search handlers composable (placed here so processApiError,
// loadData, and removeTraceId are all already declared above).
const { handleSearchResponse, handleSearchClose, handleSearchReset, handleSearchError } =
usePanelSearchHandlers({
state,
processApiError,
saveCurrentStateToCache,
loadData,
removeTraceId,
});
const {
handleSearchResponse,
handleSearchClose,
handleSearchReset,
handleSearchError,
clearHitsBuffer,
} = usePanelSearchHandlers({
state,
processApiError,
saveCurrentStateToCache,
loadData,
removeTraceId,
});
// Wire up PromQL and SQL executors (placed here so handleSearch* handlers,
// processApiError, addTraceId, removeTraceId are all already declared above).
@@ -629,7 +636,7 @@ export const usePanelDataLoader = (
removeTraceId,
});
const { executeSQL, executeMultiSQL } = usePanelSQLExecutor({
const { executeSQL, executeMultiSQL, invalidateRun } = usePanelSQLExecutor({
state,
panelSchema,
store,
@@ -651,6 +658,7 @@ export const usePanelDataLoader = (
handleSearchClose,
handleSearchError,
handleSearchReset,
clearHitsBuffer,
processApiError,
saveCurrentStateToCache,
addTraceId,
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { ref } from "vue";
import { usePanelSQLExecutor } from "./usePanelSQLExecutor";
import { usePanelSearchHandlers } from "./usePanelSearchHandlers";
// ─── module mocks ─────────────────────────────────────────────────────────────
@@ -86,6 +87,7 @@ const makeCtx = (overrides: Partial<any> = {}) => {
const handleSearchClose = vi.fn();
const handleSearchError = vi.fn();
const handleSearchReset = vi.fn();
const clearHitsBuffer = vi.fn();
const processApiError = vi.fn();
const saveCurrentStateToCache = vi.fn(async () => {});
const addTraceId = vi.fn();
@@ -117,6 +119,7 @@ const makeCtx = (overrides: Partial<any> = {}) => {
handleSearchClose,
handleSearchError,
handleSearchReset,
clearHitsBuffer,
processApiError,
saveCurrentStateToCache,
addTraceId,
@@ -187,13 +190,80 @@ describe("usePanelSQLExecutor", () => {
expect(addTraceId).toHaveBeenCalled();
});
it("single query uses standard handleSearchResponse handler", async () => {
it("single query forwards stream events to the standard handlers", async () => {
const { ctx, fetchQueryDataWithHttpStream, handleSearchResponse } = makeCtx();
const { executeSQL } = usePanelSQLExecutor(ctx);
await executeSQL(0, 300_000_000, null);
const [, handlers] = fetchQueryDataWithHttpStream.mock.calls[0];
expect(handlers.data).toBe(handleSearchResponse);
const payload = { traceId: "mock-trace-sql" };
const response = { type: "end" };
handlers.data(payload, response);
expect(handleSearchResponse).toHaveBeenCalledWith(payload, response);
});
it("ignores events from a previous run's stream once a new run has started", async () => {
const {
ctx,
fetchQueryDataWithHttpStream,
handleSearchResponse,
handleSearchClose,
handleSearchError,
handleSearchReset,
removeTraceId,
} = makeCtx();
const { executeSQL } = usePanelSQLExecutor(ctx);
await executeSQL(0, 300_000_000, null);
await executeSQL(0, 300_000_000, null);
const [[stalePayload, stale], [, current]] = fetchQueryDataWithHttpStream.mock.calls;
stale.data(stalePayload, { type: "search_response_hits" });
stale.error(stalePayload, { content: { message: "x" } });
stale.reset(stalePayload, {});
stale.complete(stalePayload, { type: "end" });
expect(handleSearchResponse).not.toHaveBeenCalled();
expect(handleSearchError).not.toHaveBeenCalled();
expect(handleSearchReset).not.toHaveBeenCalled();
expect(handleSearchClose).not.toHaveBeenCalled();
expect(removeTraceId).toHaveBeenCalledWith(stalePayload.traceId);
current.data(stalePayload, { type: "search_response_hits" });
expect(handleSearchResponse).toHaveBeenCalledTimes(1);
});
it("does not duplicate categories when a re-run overlaps the previous run's stream", async () => {
const { ctx, state, fetchQueryDataWithHttpStream } = makeCtx();
const handlers = usePanelSearchHandlers({
state,
processApiError: vi.fn(),
saveCurrentStateToCache: vi.fn(),
loadData: vi.fn(),
removeTraceId: vi.fn(),
});
Object.assign(ctx, {
handleSearchResponse: handlers.handleSearchResponse,
handleSearchClose: handlers.handleSearchClose,
handleSearchError: handlers.handleSearchError,
handleSearchReset: handlers.handleSearchReset,
});
const { executeSQL } = usePanelSQLExecutor(ctx);
await executeSQL(0, 300_000_000, null);
await executeSQL(0, 300_000_000, null);
const hits = [{ value: "OK" }, { value: "ERROR" }];
for (const [payload, streamHandlers] of fetchQueryDataWithHttpStream.mock.calls) {
streamHandlers.data(payload, {
type: "search_response_metadata",
content: { results: { streaming_aggs: false } },
});
streamHandlers.data(payload, {
type: "search_response_hits",
content: { results: { hits } },
});
streamHandlers.data(payload, { type: "end" });
}
expect(state.data[0].map((h: any) => h.value)).toEqual(["OK", "ERROR"]);
});
it("includes panel metadata in payload.meta", async () => {
@@ -534,6 +604,180 @@ describe("usePanelSQLExecutor", () => {
});
});
describe("superseded runs on the time-shift and multi-query paths", () => {
const query = (sql: string, time_shift: any[] = []) => ({
query: sql,
vrlFunctionQuery: "",
fields: { stream: "logs", stream_type: "logs", x: [{ alias: "ts" }] },
config: { time_shift },
});
const hits = [{ value: "OK" }, { value: "ERROR" }];
const streamHits = (payload: any, handlers: any) => {
handlers.data(payload, {
type: "search_response_metadata",
content: { results: { query_index: 0, streaming_aggs: false } },
});
handlers.data(payload, {
type: "search_response_hits",
content: { results: { query_index: 0, hits } },
});
};
const cases = [
{
name: "time-shift",
queries: [query("SELECT * FROM logs", [{ offSet: "1d" }])],
run: (ex: any) => ex.executeSQL(0, 300_000_000, null),
},
{
name: "multi-query",
queries: [query("SELECT * FROM logs"), query("SELECT * FROM metrics")],
run: (ex: any) => ex.executeMultiSQL(0, 300_000_000, null, "logs"),
},
];
it.each(cases)("$name: a superseded run's stream cannot write", async ({ queries, run }) => {
const {
ctx,
state,
fetchQueryDataWithHttpStream,
handleSearchError,
handleSearchReset,
removeTraceId,
} = makeCtx({ panelSchema: makePanelSchema(queries) });
const executor = usePanelSQLExecutor(ctx);
await run(executor);
await run(executor);
const [[stalePayload, stale], [currentPayload, current]] =
fetchQueryDataWithHttpStream.mock.calls;
streamHits(currentPayload, current);
streamHits(stalePayload, stale);
stale.data(stalePayload, { type: "end" });
await stale.complete(stalePayload, { type: "end" });
stale.error(stalePayload, { content: { message: "x" } });
stale.reset(stalePayload, {});
expect(state.data[0].map((h: any) => h.value)).toEqual(["OK", "ERROR"]);
expect(state.loading).toBe(true);
expect(handleSearchError).not.toHaveBeenCalled();
expect(handleSearchReset).not.toHaveBeenCalled();
expect(removeTraceId).toHaveBeenCalledWith(stalePayload.traceId);
});
it.each([
{ name: "single query", queries: [query("SELECT * FROM logs")], multi: false },
{
name: "time-shift",
queries: [query("SELECT * FROM logs", [{ offSet: "1d" }])],
multi: false,
},
{ name: "multi-query", queries: cases[1].queries, multi: true },
])(
"$name: a run superseded while awaiting variables fires and writes nothing",
async ({ queries, multi }) => {
const { ctx, state, fetchQueryDataWithHttpStream } = makeCtx({
panelSchema: makePanelSchema(queries),
});
let resumeA!: () => void;
const gate = new Promise<void>((resolve) => (resumeA = resolve));
let calls = 0;
ctx.applyDynamicVariables = vi.fn(async (q: string) => {
if (calls++ === 0) await gate;
return { query: q, metadata: [] };
});
const executor = usePanelSQLExecutor(ctx);
const run = () =>
multi
? executor.executeMultiSQL(0, 300_000_000, null, "logs")
: executor.executeSQL(0, 300_000_000, null);
const runA = run();
await run();
const afterB = JSON.stringify([state.data, state.metadata, state.resultMetaData]);
resumeA();
await runA;
expect(fetchQueryDataWithHttpStream).toHaveBeenCalledTimes(1);
expect(JSON.stringify([state.data, state.metadata, state.resultMetaData])).toBe(afterB);
},
);
it("a re-run discards progress the previous run's stream buffered but had not flushed", async () => {
const { ctx, state, fetchQueryDataWithHttpStream } = makeCtx();
const handlers = usePanelSearchHandlers({
state,
processApiError: vi.fn(),
saveCurrentStateToCache: vi.fn(),
loadData: vi.fn(),
removeTraceId: vi.fn(),
});
Object.assign(ctx, {
handleSearchResponse: handlers.handleSearchResponse,
clearHitsBuffer: handlers.clearHitsBuffer,
});
const { executeSQL } = usePanelSQLExecutor(ctx);
await executeSQL(0, 300_000_000, null);
const [[payloadA, streamA]] = fetchQueryDataWithHttpStream.mock.calls;
streamA.data(payloadA, { type: "event_progress", content: { percent: 42 } });
const runB = executeSQL(0, 300_000_000, null);
await runB;
expect(state.loadingProgressPercentage).toBe(0);
expect(state.isPartialData).toBe(false);
});
it.each([
{ name: "single query", queries: [query("SELECT * FROM logs")], multi: false },
{ name: "multi-query", queries: cases[1].queries, multi: true },
])(
"$name: a prefetched run superseded while awaiting annotations does not complete",
async ({ queries, multi }) => {
let resolveAnnotations!: (v: any) => void;
const { ctx, state } = makeCtx({
panelSchema: makePanelSchema(queries),
shouldFetchAnnotations: vi.fn(() => true),
refreshAnnotations: vi
.fn()
.mockImplementationOnce(() => new Promise((r) => (resolveAnnotations = r)))
.mockResolvedValue([]),
});
const executor = usePanelSQLExecutor(ctx);
const run = () =>
multi
? executor.executeMultiSQL(0, 300_000_000, null, "logs")
: executor.executeSQL(0, 300_000_000, null);
ctx.searchResponse.value = { hits: [{ value: "prefetched" }] };
const runA = run();
await vi.waitFor(() => expect(ctx.refreshAnnotations).toHaveBeenCalled());
ctx.searchResponse.value = null;
await run();
state.loading = true;
resolveAnnotations([{ id: "stale-annotation" }]);
await runA;
expect(state.loading).toBe(true);
expect(state.annotations).toEqual([]);
},
);
it("multi-query: the searchResponse early return still supersedes an in-flight stream", async () => {
const { ctx, state, fetchQueryDataWithHttpStream } = makeCtx({
panelSchema: makePanelSchema(cases[1].queries),
});
const { executeMultiSQL } = usePanelSQLExecutor(ctx);
await executeMultiSQL(0, 300_000_000, null, "logs");
ctx.searchResponse.value = { hits: [{ value: "cached" }] };
await executeMultiSQL(0, 300_000_000, null, "logs");
const [[stalePayload, stale]] = fetchQueryDataWithHttpStream.mock.calls;
streamHits(stalePayload, stale);
expect(state.data.flat().map((h: any) => h.value)).toEqual(["cached"]);
});
});
describe("getRegionClusterParams integration", () => {
it("spreads region cluster params into queryReq for simple queries", async () => {
const { ctx, fetchQueryDataWithHttpStream } = makeCtx({
@@ -46,6 +46,7 @@ export const usePanelSQLExecutor = (ctx: {
handleSearchClose: any;
handleSearchError: any;
handleSearchReset: any;
clearHitsBuffer: () => void;
processApiError: (error: any, type: any) => void;
saveCurrentStateToCache: () => Promise<void>;
addTraceId: (traceId: string) => void;
@@ -77,6 +78,7 @@ export const usePanelSQLExecutor = (ctx: {
handleSearchClose,
handleSearchError,
handleSearchReset,
clearHitsBuffer,
processApiError,
saveCurrentStateToCache,
addTraceId,
@@ -129,6 +131,7 @@ export const usePanelSQLExecutor = (ctx: {
pageType: string,
currentQueryIndex: number,
abortControllerRef: any,
runToken: number,
) => {
try {
const { traceId } = generateTraceContext();
@@ -179,6 +182,7 @@ export const usePanelSQLExecutor = (ctx: {
clear_cache: shouldRefreshWithoutCache?.value || false,
};
if (runToken !== sqlRunToken) return;
// if aborted, return
if (abortControllerRef?.signal?.aborted) {
// Set partial data flag on abort
@@ -188,12 +192,15 @@ export const usePanelSQLExecutor = (ctx: {
return;
}
fetchQueryDataWithHttpStream(payload, {
data: handleSearchResponse,
error: handleSearchError,
complete: handleSearchClose,
reset: handleSearchReset,
});
fetchQueryDataWithHttpStream(
payload,
dropSupersededRunEvents(runToken, {
data: handleSearchResponse,
error: handleSearchError,
complete: handleSearchClose,
reset: handleSearchReset,
}),
);
addTraceId(traceId);
} catch (e: any) {
@@ -207,10 +214,26 @@ export const usePanelSQLExecutor = (ctx: {
}
};
// Bumped by executeSQL on every run. A fire-and-forget sparkline stream captures
// the token at fire time and only writes if it still matches — so a slow stream
// from a previous range/variable can't overwrite the freshly-reset state.
let sparklineRunToken = 0;
// Bumped per run; streams fired by a superseded run drop their writes.
let sqlRunToken = 0;
// A superseded run's stream keeps flowing; letting it write appends its hits to the new run's data.
const dropSupersededRunEvents = (
runToken: number,
handlers: Record<"data" | "error" | "complete" | "reset", (p: any, r: any) => any>,
) => {
const isCurrentRun = (p: any, freeTraceId: boolean) => {
if (runToken === sqlRunToken) return true;
if (freeTraceId) removeTraceId(p?.traceId);
return false;
};
return {
data: (p: any, r: any) => isCurrentRun(p, false) && handlers.data(p, r),
error: (p: any, r: any) => isCurrentRun(p, true) && handlers.error(p, r),
complete: (p: any, r: any) => isCurrentRun(p, true) && handlers.complete(p, r),
reset: (p: any, r: any) => isCurrentRun(p, false) && handlers.reset(p, r),
};
};
// Isolated 2nd fetch: a UI histogram (is_ui_histogram=true) of the SAME query,
// used ONLY to draw the metric sparkline. Fully guarded and fire-and-forget —
@@ -223,11 +246,10 @@ export const usePanelSQLExecutor = (ctx: {
pageType: string,
currentQueryIndex: number,
abortControllerRef: any,
runToken: number,
) => {
try {
if (abortControllerRef?.signal?.aborted) return;
// Snapshot the current run; a later run bumps this and invalidates our writes.
const runToken = sparklineRunToken;
if (abortControllerRef?.signal?.aborted || runToken !== sqlRunToken) return;
const { traceId } = generateTraceContext();
const hits: any[] = [];
const payload: any = {
@@ -261,7 +283,7 @@ export const usePanelSQLExecutor = (ctx: {
// renders. Other transient errors stay silent. The API delivers this either
// as a `data` event of type "error" or via the stream `error` callback.
const captureSparklineError = (content: any) => {
if (runToken !== sparklineRunToken) return;
if (runToken !== sqlRunToken) return;
if (content?.code === 20013) {
state.sparklineWarning = content?.error_detail || content?.message || "";
}
@@ -270,7 +292,7 @@ export const usePanelSQLExecutor = (ctx: {
// streams in (the render watcher tracks sparklineData) instead of appearing
// once at the end. Dropped if a newer run already reset the state.
const commitHits = () => {
if (runToken !== sparklineRunToken) return;
if (runToken !== sqlRunToken) return;
// Reassign the whole array so the render watcher (shallow ref) fires.
const next = Array.isArray(state.sparklineData) ? state.sparklineData.slice() : [];
next[currentQueryIndex] = hits.slice();
@@ -320,8 +342,9 @@ export const usePanelSQLExecutor = (ctx: {
queries: [],
};
state.resultMetaData = [];
// Invalidate any in-flight sparkline stream from a previous run before reset.
sparklineRunToken++;
// Supersede the previous run so its still-flowing streams drop their writes.
const runToken = ++sqlRunToken;
clearHitsBuffer();
state.sparklineData = [];
state.sparklineWarning = "";
state.annotations = [];
@@ -367,6 +390,7 @@ export const usePanelSQLExecutor = (ctx: {
query1,
panelSchema.value.queryType,
);
if (runToken !== sqlRunToken) return;
const query = query2;
// Validate that timestamp column is not used as an alias for other fields
@@ -380,6 +404,7 @@ export const usePanelSQLExecutor = (ctx: {
addTraceId("tempTraceId");
await nextTick();
removeTraceId("tempTraceId");
if (runToken !== sqlRunToken) return;
state.loading = false;
// Skip this iteration and continue with next query/time shift
continue;
@@ -482,141 +507,145 @@ export const usePanelSQLExecutor = (ctx: {
},
};
fetchQueryDataWithHttpStream(payload, {
data: (payload: any, response: any) => {
// Handle streaming response for multi-query
if (response.type === "search_response_metadata") {
const results = response?.content?.results;
fetchQueryDataWithHttpStream(
payload,
dropSupersededRunEvents(runToken, {
data: (payload: any, response: any) => {
// Handle streaming response for multi-query
if (response.type === "search_response_metadata") {
const results = response?.content?.results;
const queryIndex = results?.query_index ?? 0;
const queryIndex = results?.query_index ?? 0;
// Store the current query index for the next hits event
currentQueryIndexInStream = queryIndex;
// Store the current query index for the next hits event
currentQueryIndexInStream = queryIndex;
// Initialize metadata array if not exists
if (!state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex] = [];
}
// Detect chunking direction from first metadata entry
if (state.resultMetaData[queryIndex].length === 0) {
const metaContent = {
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
};
const direction = detectChunkingDirection(
metaContent?.time_offset?.start_time ?? 0,
metaContent?.time_offset?.end_time ?? 0,
state.metadata?.queries?.[queryIndex]?.startTime ??
state.metadata?.queries?.[0]?.startTime ??
0,
state.metadata?.queries?.[queryIndex]?.endTime ??
state.metadata?.queries?.[0]?.endTime ??
0,
);
if (direction !== null) {
chunkingLeftToRight.set(queryIndex, direction);
// Initialize metadata array if not exists
if (!state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex] = [];
}
}
// Push metadata for each partition
state.resultMetaData[queryIndex].push({
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
});
}
if (response.type === "search_response_hits") {
// The hits come directly in response.content.hits or response.content.results.hits
const hits = response?.content?.results?.hits ?? response?.content?.hits;
// Get query_index from results metadata
const results = response?.content?.results;
// Use query_index from the event, or from the last metadata event, or find next empty
let queryIndex = results?.query_index ?? currentQueryIndexInStream;
// If query_index is still not available, find the first query that doesn't have hits yet
if (queryIndex === undefined || queryIndex === null) {
queryIndex = state.resultMetaData.findIndex(
(meta: any, idx: number) => !state.data[idx] || state.data[idx].length === 0,
);
}
if (
queryIndex >= 0 &&
queryIndex < state.data.length &&
Array.isArray(hits) &&
hits.length > 0
) {
// Check if streaming_aggs is enabled
const streaming_aggs =
state.resultMetaData[queryIndex]?.[0]?.streaming_aggs ?? false;
// If streaming_aggs, replace the data (aggregation query)
if (streaming_aggs) {
state.data[queryIndex] = markRaw([...hits]);
}
// Otherwise, append/prepend based on chunking direction and order_by
else {
const orderAsc =
state.resultMetaData[queryIndex]?.order_by?.toLowerCase() === "asc";
const isLTR = chunkingLeftToRight.get(queryIndex) ?? false;
const shouldPrepend = shouldPrependChunk(isLTR, orderAsc);
if (shouldPrepend) {
state.data[queryIndex] = markRaw([
...hits,
...toRaw(state.data[queryIndex] ?? []),
]);
} else {
state.data[queryIndex] = markRaw([
...toRaw(state.data[queryIndex] ?? []),
...hits,
]);
// Detect chunking direction from first metadata entry
if (state.resultMetaData[queryIndex].length === 0) {
const metaContent = {
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
};
const direction = detectChunkingDirection(
metaContent?.time_offset?.start_time ?? 0,
metaContent?.time_offset?.end_time ?? 0,
state.metadata?.queries?.[queryIndex]?.startTime ??
state.metadata?.queries?.[0]?.startTime ??
0,
state.metadata?.queries?.[queryIndex]?.endTime ??
state.metadata?.queries?.[0]?.endTime ??
0,
);
if (direction !== null) {
chunkingLeftToRight.set(queryIndex, direction);
}
}
if (state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex].hits = state.data[queryIndex];
// Push metadata for each partition
state.resultMetaData[queryIndex].push({
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
});
}
if (response.type === "search_response_hits") {
// The hits come directly in response.content.hits or response.content.results.hits
const hits = response?.content?.results?.hits ?? response?.content?.hits;
// Get query_index from results metadata
const results = response?.content?.results;
// Use query_index from the event, or from the last metadata event, or find next empty
let queryIndex = results?.query_index ?? currentQueryIndexInStream;
// If query_index is still not available, find the first query that doesn't have hits yet
if (queryIndex === undefined || queryIndex === null) {
queryIndex = state.resultMetaData.findIndex(
(meta: any, idx: number) =>
!state.data[idx] || state.data[idx].length === 0,
);
}
if (
queryIndex >= 0 &&
queryIndex < state.data.length &&
Array.isArray(hits) &&
hits.length > 0
) {
// Check if streaming_aggs is enabled
const streaming_aggs =
state.resultMetaData[queryIndex]?.[0]?.streaming_aggs ?? false;
// If streaming_aggs, replace the data (aggregation query)
if (streaming_aggs) {
state.data[queryIndex] = markRaw([...hits]);
}
// Otherwise, append/prepend based on chunking direction and order_by
else {
const orderAsc =
state.resultMetaData[queryIndex]?.order_by?.toLowerCase() === "asc";
const isLTR = chunkingLeftToRight.get(queryIndex) ?? false;
const shouldPrepend = shouldPrependChunk(isLTR, orderAsc);
if (shouldPrepend) {
state.data[queryIndex] = markRaw([
...hits,
...toRaw(state.data[queryIndex] ?? []),
]);
} else {
state.data[queryIndex] = markRaw([
...toRaw(state.data[queryIndex] ?? []),
...hits,
]);
}
}
if (state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex].hits = state.data[queryIndex];
}
}
state.errorDetail = { message: "", code: "" };
// saveCurrentStateToCache();
}
state.errorDetail = { message: "", code: "" };
// saveCurrentStateToCache();
}
if (response.type === "search_response") {
// Legacy format: single response with all data
const results = response?.content?.results;
const queryIndex = results?.query_index ?? 0;
if (response.type === "search_response") {
// Legacy format: single response with all data
const results = response?.content?.results;
const queryIndex = results?.query_index ?? 0;
if (results?.hits && Array.isArray(results.hits)) {
state.data[queryIndex] = markRaw([...results.hits]);
state.resultMetaData[queryIndex] = {
...(state.resultMetaData[queryIndex] ?? {}),
...results,
};
if (results?.hits && Array.isArray(results.hits)) {
state.data[queryIndex] = markRaw([...results.hits]);
state.resultMetaData[queryIndex] = {
...(state.resultMetaData[queryIndex] ?? {}),
...results,
};
}
state.errorDetail = { message: "", code: "" };
}
state.errorDetail = { message: "", code: "" };
}
if (response.type === "error") {
processApiError(response?.content, "sql");
}
if (response.type === "error") {
processApiError(response?.content, "sql");
}
if (response.type === "end") {
if (response.type === "end") {
state.loading = false;
state.isPartialData = false;
saveCurrentStateToCache();
}
},
error: handleSearchError,
complete: async () => {
state.loading = false;
state.isPartialData = false;
saveCurrentStateToCache();
}
},
error: handleSearchError,
complete: async () => {
state.loading = false;
saveCurrentStateToCache();
removeTraceId(traceId);
},
reset: handleSearchReset,
});
removeTraceId(traceId);
},
reset: handleSearchReset,
}),
);
// Wait for annotations to complete (started in parallel earlier)
state.annotations = await annotationsPromise;
@@ -640,6 +669,7 @@ export const usePanelSQLExecutor = (ctx: {
query1,
panelSchema.value.queryType,
);
if (runToken !== sqlRunToken) return;
const query = query2;
@@ -655,6 +685,7 @@ export const usePanelSQLExecutor = (ctx: {
addTraceId("tempTraceId");
await nextTick();
removeTraceId("tempTraceId");
if (runToken !== sqlRunToken) return;
// Skip executing this query and move to next
continue;
}
@@ -719,7 +750,9 @@ export const usePanelSQLExecutor = (ctx: {
// Wait for annotations to complete
if (annotationsPromise) {
state.annotations = await annotationsPromise;
const annotations = await annotationsPromise;
if (runToken !== sqlRunToken) return;
state.annotations = annotations;
}
// set loading to false
@@ -757,6 +790,7 @@ export const usePanelSQLExecutor = (ctx: {
pageType,
panelQueryIndex,
abortControllerRef,
runToken,
);
// Best-effort 2nd fetch for the metric sparkline trend (isolated).
@@ -769,6 +803,7 @@ export const usePanelSQLExecutor = (ctx: {
pageType,
panelQueryIndex,
abortControllerRef,
runToken,
);
}
@@ -807,6 +842,9 @@ export const usePanelSQLExecutor = (ctx: {
abortControllerRef: any,
pageType: string,
) => {
// Bumped before the pre-fetch early return so it also supersedes in-flight streams.
const runToken = ++sqlRunToken;
clearHitsBuffer();
// Handle searchResponse pre-fetch early return
if (searchResponse?.value?.hits?.length > 0) {
state.loading = true;
@@ -843,7 +881,9 @@ export const usePanelSQLExecutor = (ctx: {
},
];
state.annotations = await annotationsPromise;
const annotations = await annotationsPromise;
if (runToken !== sqlRunToken) return;
state.annotations = annotations;
state.loading = false;
return;
}
@@ -854,8 +894,6 @@ export const usePanelSQLExecutor = (ctx: {
queries: [],
};
state.resultMetaData = [];
// Invalidate any in-flight sparkline stream from a previous run before reset.
sparklineRunToken++;
state.sparklineData = [];
state.sparklineWarning = "";
state.annotations = [];
@@ -896,6 +934,7 @@ export const usePanelSQLExecutor = (ctx: {
query1,
panelSchema.value.queryType,
);
if (runToken !== sqlRunToken) return;
const query = query2;
if (!checkTimestampAlias(query)) {
@@ -908,6 +947,7 @@ export const usePanelSQLExecutor = (ctx: {
addTraceId("tempTraceId");
await nextTick();
removeTraceId("tempTraceId");
if (runToken !== sqlRunToken) return;
state.loading = false;
continue;
}
@@ -944,6 +984,7 @@ export const usePanelSQLExecutor = (ctx: {
query1,
panelSchema.value.queryType,
);
if (runToken !== sqlRunToken) return;
const query = query2;
if (!checkTimestampAlias(query)) {
@@ -957,6 +998,7 @@ export const usePanelSQLExecutor = (ctx: {
addTraceId("tempTraceId");
await nextTick();
removeTraceId("tempTraceId");
if (runToken !== sqlRunToken) return;
continue;
}
@@ -1009,6 +1051,7 @@ export const usePanelSQLExecutor = (ctx: {
pageType,
i,
abortControllerRef,
runToken,
);
}
}
@@ -1074,122 +1117,138 @@ export const usePanelSQLExecutor = (ctx: {
},
};
fetchQueryDataWithHttpStream(payload, {
data: (_payload: any, response: any) => {
if (response.type === "search_response_metadata") {
const results = response?.content?.results;
const queryIndex = results?.query_index ?? 0;
fetchQueryDataWithHttpStream(
payload,
dropSupersededRunEvents(runToken, {
data: (_payload: any, response: any) => {
if (response.type === "search_response_metadata") {
const results = response?.content?.results;
const queryIndex = results?.query_index ?? 0;
currentQueryIndexInStream = queryIndex;
currentQueryIndexInStream = queryIndex;
if (!state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex] = [];
}
// Detect chunking direction from first metadata entry
if (state.resultMetaData[queryIndex].length === 0) {
const metaContent = {
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
};
const direction = detectChunkingDirection(
metaContent?.time_offset?.start_time ?? 0,
metaContent?.time_offset?.end_time ?? 0,
state.metadata?.queries?.[queryIndex]?.startTime ??
state.metadata?.queries?.[0]?.startTime ??
0,
state.metadata?.queries?.[queryIndex]?.endTime ??
state.metadata?.queries?.[0]?.endTime ??
0,
);
if (direction !== null) {
chunkingLeftToRight.set(queryIndex, direction);
if (!state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex] = [];
}
}
state.resultMetaData[queryIndex].push({
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
});
}
if (response.type === "search_response_hits") {
const hits = response?.content?.results?.hits ?? response?.content?.hits;
const results = response?.content?.results;
let queryIndex = results?.query_index ?? currentQueryIndexInStream;
if (queryIndex === undefined || queryIndex === null) {
queryIndex = state.resultMetaData.findIndex(
(_meta: any, idx: number) => !state.data[idx] || state.data[idx].length === 0,
);
}
if (
queryIndex >= 0 &&
queryIndex < state.data.length &&
Array.isArray(hits) &&
hits.length > 0
) {
const streaming_aggs = state.resultMetaData[queryIndex]?.[0]?.streaming_aggs ?? false;
if (streaming_aggs) {
state.data[queryIndex] = markRaw([...hits]);
} else {
const orderAsc = state.resultMetaData[queryIndex]?.order_by?.toLowerCase() === "asc";
const isLTR = chunkingLeftToRight.get(queryIndex) ?? false;
const shouldPrepend = shouldPrependChunk(isLTR, orderAsc);
if (shouldPrepend) {
state.data[queryIndex] = markRaw([...hits, ...toRaw(state.data[queryIndex] ?? [])]);
} else {
state.data[queryIndex] = markRaw([...toRaw(state.data[queryIndex] ?? []), ...hits]);
// Detect chunking direction from first metadata entry
if (state.resultMetaData[queryIndex].length === 0) {
const metaContent = {
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
};
const direction = detectChunkingDirection(
metaContent?.time_offset?.start_time ?? 0,
metaContent?.time_offset?.end_time ?? 0,
state.metadata?.queries?.[queryIndex]?.startTime ??
state.metadata?.queries?.[0]?.startTime ??
0,
state.metadata?.queries?.[queryIndex]?.endTime ??
state.metadata?.queries?.[0]?.endTime ??
0,
);
if (direction !== null) {
chunkingLeftToRight.set(queryIndex, direction);
}
}
if (state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex].hits = state.data[queryIndex];
state.resultMetaData[queryIndex].push({
...(response?.content ?? {}),
...(response?.content?.results ?? {}),
});
}
if (response.type === "search_response_hits") {
const hits = response?.content?.results?.hits ?? response?.content?.hits;
const results = response?.content?.results;
let queryIndex = results?.query_index ?? currentQueryIndexInStream;
if (queryIndex === undefined || queryIndex === null) {
queryIndex = state.resultMetaData.findIndex(
(_meta: any, idx: number) => !state.data[idx] || state.data[idx].length === 0,
);
}
if (
queryIndex >= 0 &&
queryIndex < state.data.length &&
Array.isArray(hits) &&
hits.length > 0
) {
const streaming_aggs = state.resultMetaData[queryIndex]?.[0]?.streaming_aggs ?? false;
if (streaming_aggs) {
state.data[queryIndex] = markRaw([...hits]);
} else {
const orderAsc =
state.resultMetaData[queryIndex]?.order_by?.toLowerCase() === "asc";
const isLTR = chunkingLeftToRight.get(queryIndex) ?? false;
const shouldPrepend = shouldPrependChunk(isLTR, orderAsc);
if (shouldPrepend) {
state.data[queryIndex] = markRaw([
...hits,
...toRaw(state.data[queryIndex] ?? []),
]);
} else {
state.data[queryIndex] = markRaw([
...toRaw(state.data[queryIndex] ?? []),
...hits,
]);
}
}
if (state.resultMetaData[queryIndex]) {
state.resultMetaData[queryIndex].hits = state.data[queryIndex];
}
}
state.errorDetail = { message: "", code: "" };
}
state.errorDetail = { message: "", code: "" };
}
if (response.type === "search_response") {
const results = response?.content?.results;
const queryIndex = results?.query_index ?? 0;
if (response.type === "search_response") {
const results = response?.content?.results;
const queryIndex = results?.query_index ?? 0;
if (results?.hits && Array.isArray(results.hits)) {
state.data[queryIndex] = markRaw([...results.hits]);
state.resultMetaData[queryIndex] = {
...(state.resultMetaData[queryIndex] ?? {}),
...results,
};
if (results?.hits && Array.isArray(results.hits)) {
state.data[queryIndex] = markRaw([...results.hits]);
state.resultMetaData[queryIndex] = {
...(state.resultMetaData[queryIndex] ?? {}),
...results,
};
}
state.errorDetail = { message: "", code: "" };
}
state.errorDetail = { message: "", code: "" };
}
if (response.type === "error") {
processApiError(response?.content, "sql");
}
if (response.type === "error") {
processApiError(response?.content, "sql");
}
if (response.type === "end") {
if (response.type === "end") {
state.loading = false;
state.isPartialData = false;
saveCurrentStateToCache();
}
},
error: handleSearchError,
complete: async () => {
state.loading = false;
state.isPartialData = false;
saveCurrentStateToCache();
}
},
error: handleSearchError,
complete: async () => {
state.loading = false;
saveCurrentStateToCache();
removeTraceId(traceId);
},
reset: handleSearchReset,
});
removeTraceId(traceId);
},
reset: handleSearchReset,
}),
);
// Wait for annotations to complete (started in parallel)
state.annotations = await annotationsPromise;
};
return { executeSQL, executeMultiSQL };
// Lets the loader supersede the running stream as soon as it schedules a rerun, not after its debounce.
const invalidateRun = () => {
sqlRunToken++;
clearHitsBuffer();
};
return { executeSQL, executeMultiSQL, invalidateRun };
};
@@ -389,5 +389,6 @@ export const usePanelSearchHandlers = ({
handleSearchClose,
handleSearchReset,
handleSearchError,
clearHitsBuffer,
};
};
@@ -192,212 +192,6 @@ describe("useTraceProcessing", () => {
});
});
describe("findCriticalPath", () => {
it("should return empty array for empty tree", () => {
const spans = ref([]);
const { findCriticalPath } = useTraceProcessing(spans, emptySpanMap, defaultConfig);
expect(findCriticalPath([])).toEqual([]);
});
it("should return single span id for single span", () => {
const spans = ref([]);
const { buildSpanTree, findCriticalPath } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const span = makeSpan({ span_id: "root", duration: 1_000_000 });
const tree = buildSpanTree([span]);
const path = findCriticalPath(tree);
expect(path).toContain("root");
});
it("should return path through longest duration branch", () => {
const spans = ref([]);
const { buildSpanTree, findCriticalPath } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const parent = makeSpan({ span_id: "parent", duration: 1_000_000 });
const shortChild = makeSpan({
span_id: "short",
parent_span_id: "parent",
duration: 500_000,
start_time: 1_000_000_100_000,
});
const longChild = makeSpan({
span_id: "long",
parent_span_id: "parent",
duration: 900_000,
start_time: 1_000_000_200_000,
});
const tree = buildSpanTree([parent, shortChild, longChild]);
const path = findCriticalPath(tree);
expect(path).toContain("parent");
expect(path).toContain("long");
expect(path).not.toContain("short");
});
});
describe("calculateMetadata", () => {
it("should throw for empty trace", () => {
const spans = ref([]);
const { calculateMetadata } = useTraceProcessing(spans, emptySpanMap, defaultConfig);
expect(() => calculateMetadata("trace-1", [])).toThrow();
});
it("should calculate basic metadata for a single span", () => {
const spans = ref([]);
const { buildSpanTree, calculateMetadata } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const span = makeSpan({
span_id: "root",
service_name: "svc-a",
span_status: SpanStatus.OK,
duration: 1_000_000, // 1s in us
});
const tree = buildSpanTree([span]);
const meta = calculateMetadata("trace-1", tree);
expect(meta.trace_id).toBe("trace-1");
expect(meta.total_spans).toBe(1);
expect(meta.error_spans).toBe(0);
expect(meta.service_count).toBe(1);
expect(meta.services).toContain("svc-a");
expect(meta.has_errors).toBe(false);
});
it("should count error spans correctly", () => {
const spans = ref([]);
const { buildSpanTree, calculateMetadata } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const root = makeSpan({ span_id: "root", span_status: SpanStatus.OK });
const error = makeSpan({
span_id: "err",
parent_span_id: "root",
span_status: SpanStatus.ERROR,
start_time: 1_000_000_100_000,
});
const tree = buildSpanTree([root, error]);
const meta = calculateMetadata("trace-1", tree);
expect(meta.error_spans).toBe(1);
expect(meta.has_errors).toBe(true);
expect(meta.error_services).toContain("svc-a");
});
it("should count multiple services", () => {
const spans = ref([]);
const { buildSpanTree, calculateMetadata } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const span1 = makeSpan({ span_id: "s1", service_name: "svc-a" });
const span2 = makeSpan({
span_id: "s2",
service_name: "svc-b",
parent_span_id: "s1",
start_time: 1_000_000_100_000,
});
const tree = buildSpanTree([span1, span2]);
const meta = calculateMetadata("trace-1", tree);
expect(meta.service_count).toBe(2);
expect(meta.services).toContain("svc-a");
expect(meta.services).toContain("svc-b");
});
it("should identify top 5 slowest spans", () => {
const spans = ref([]);
const { buildSpanTree, calculateMetadata } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const root = makeSpan({ span_id: "root", duration: 1_000_000 });
const spansArr = [root];
for (let i = 0; i < 6; i++) {
spansArr.push(
makeSpan({
span_id: `child-${i}`,
parent_span_id: "root",
duration: (i + 1) * 100_000,
start_time: 1_000_000_100_000 + i * 1000,
}),
);
}
const tree = buildSpanTree(spansArr);
const meta = calculateMetadata("trace-1", tree);
expect(meta.slowest_spans).toHaveLength(5);
});
});
describe("calculateServiceBreakdown", () => {
it("should return a breakdown per service", () => {
const spans = ref([]);
const { buildSpanTree, calculateMetadata, calculateServiceBreakdown } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const span1 = makeSpan({
span_id: "s1",
service_name: "svc-a",
duration: 1_000_000,
});
const span2 = makeSpan({
span_id: "s2",
service_name: "svc-b",
parent_span_id: "s1",
duration: 500_000,
start_time: 1_000_000_100_000,
});
const tree = buildSpanTree([span1, span2]);
const meta = calculateMetadata("trace-1", tree);
const breakdown = calculateServiceBreakdown(meta);
expect(breakdown).toHaveLength(2);
const svcA = breakdown.find((b) => b.service_name === "svc-a");
expect(svcA).toBeDefined();
expect(svcA!.span_count).toBe(1);
// getOrSetServiceColor returns hex colors (e.g. #3B82F6), not CSS vars
expect(svcA!.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
it("should sort by total duration descending", () => {
const spans = ref([]);
const { buildSpanTree, calculateMetadata, calculateServiceBreakdown } = useTraceProcessing(
spans,
emptySpanMap,
defaultConfig,
);
const span1 = makeSpan({
span_id: "s1",
service_name: "slow-svc",
duration: 2_000_000,
});
const span2 = makeSpan({
span_id: "s2",
service_name: "fast-svc",
parent_span_id: "s1",
duration: 100_000,
start_time: 1_000_000_100_000,
});
const tree = buildSpanTree([span1, span2]);
const meta = calculateMetadata("trace-1", tree);
const breakdown = calculateServiceBreakdown(meta);
expect(breakdown[0].service_name).toBe("slow-svc");
});
});
describe("filterSpans", () => {
it("should return all spans when filter is empty", () => {
const spans = ref([]);
@@ -11,7 +11,6 @@ import {
SpanKind,
SpanStatus,
} from "@/ts/interfaces/traces/span.types";
import { type TraceMetadata, type ServiceBreakdown } from "@/ts/interfaces/traces/trace.types";
import type { ServiceDetectionConfig } from "@/ts/interfaces/traces/serviceDetection.types";
import { useSpanServiceDetection } from "@/utils/traces/useSpanServiceDetection";
import { getOrSetServiceColor } from "@/utils/traces/serviceColorRegistry";
@@ -67,7 +66,6 @@ export function useTraceProcessing(
hasChildren: !!(node.spans && node.spans.length > 0),
isExpanded: true,
isSelected: false,
isOnCriticalPath: false,
resolvedIdentity,
color: getOrSetServiceColor(resolvedIdentity),
durationMs: node.durationMs || 0,
@@ -124,7 +122,6 @@ export function useTraceProcessing(
hasChildren: false,
isExpanded: true,
isSelected: false,
isOnCriticalPath: false,
resolvedIdentity,
color: getOrSetServiceColor(resolvedIdentity),
durationMs: span.duration / 1000, // Convert from microseconds to milliseconds
@@ -187,143 +184,6 @@ export function useTraceProcessing(
return result;
};
/**
* Find critical path
*/
const findCriticalPath = (rootSpans: EnrichedSpan[]): string[] => {
let longestPath: string[] = [];
let longestDuration = 0;
const traverse = (span: EnrichedSpan, path: string[], duration: number) => {
const newPath = [...path, span.span_id];
const newDuration = duration + span.durationMs;
if (span.children.length === 0) {
if (newDuration > longestDuration) {
longestDuration = newDuration;
longestPath = newPath;
}
} else {
span.children.forEach((child) => traverse(child, newPath, newDuration));
}
};
rootSpans.forEach((span) => traverse(span, [], 0));
return longestPath;
};
/**
* Calculate trace metadata
*/
const calculateMetadata = (traceId: string, spanTree: EnrichedSpan[]): TraceMetadata => {
const allSpans = flattenSpanTree(spanTree);
if (allSpans.length === 0) {
throw new Error("Cannot calculate metadata for empty trace");
}
const services = new Set<string>();
const serviceSpans = new Map<string, number>();
const serviceDurations = new Map<string, number>();
const spanKinds = new Map<string, number>();
const errorServices = new Set<string>();
const errorMessages = new Set<string>();
let errorCount = 0;
let okCount = 0;
let unsetCount = 0;
let minTime = Infinity;
let maxTime = 0;
allSpans.forEach((span) => {
services.add(span.service_name);
serviceSpans.set(span.service_name, (serviceSpans.get(span.service_name) || 0) + 1);
serviceDurations.set(
span.service_name,
(serviceDurations.get(span.service_name) || 0) + span.durationMs,
);
const kind = span.span_kind || SpanKind.UNSPECIFIED;
spanKinds.set(kind, (spanKinds.get(kind) || 0) + 1);
if (span.span_status === SpanStatus.ERROR) {
errorCount++;
errorServices.add(span.service_name);
if (span.attributes?.["error.message"]) {
errorMessages.add(span.attributes["error.message"]);
}
} else if (span.span_status === SpanStatus.OK) {
okCount++;
} else {
unsetCount++;
}
minTime = Math.min(minTime, span.start_time);
maxTime = Math.max(maxTime, span.end_time);
});
const sorted = [...allSpans].sort((a, b) => b.durationMs - a.durationMs);
const slowestSpans = sorted.slice(0, 5);
const criticalPathIds = findCriticalPath(spanTree);
const criticalPathDuration = criticalPathIds.reduce((sum, id) => {
const span = allSpans.find((s) => s.span_id === id);
return sum + (span?.durationMs || 0);
}, 0);
const totalDuration = (maxTime - minTime) / 1000000;
return {
trace_id: traceId,
root_service: spanTree[0]?.service_name || "unknown",
root_operation: spanTree[0]?.operation_name || "unknown",
start_time: minTime,
end_time: maxTime,
duration_ms: totalDuration,
total_spans: allSpans.length,
error_spans: errorCount,
service_count: services.size,
services: Array.from(services),
service_spans: serviceSpans,
service_durations: serviceDurations,
span_kinds: spanKinds,
status_counts: { ok: okCount, error: errorCount, unset: unsetCount },
critical_path_duration: criticalPathDuration,
critical_path_percent: (criticalPathDuration / totalDuration) * 100,
slowest_spans: slowestSpans,
has_errors: errorCount > 0,
error_services: Array.from(errorServices),
error_messages: Array.from(errorMessages),
};
};
/**
* Calculate service breakdown
*/
const calculateServiceBreakdown = (metadata: TraceMetadata): ServiceBreakdown[] => {
const breakdown: ServiceBreakdown[] = [];
metadata.services.forEach((serviceName) => {
const spanCount = metadata.service_spans.get(serviceName) || 0;
const totalDuration = metadata.service_durations.get(serviceName) || 0;
const percentage = (totalDuration / metadata.duration_ms) * 100;
const hasErrors = metadata.error_services.includes(serviceName);
breakdown.push({
service_name: serviceName,
span_count: spanCount,
total_duration_ms: totalDuration,
percentage,
color: getOrSetServiceColor(serviceName),
has_errors: hasErrors,
error_count: hasErrors ? metadata.error_spans : 0,
});
});
breakdown.sort((a, b) => b.total_duration_ms - a.total_duration_ms);
return breakdown;
};
/**
* Filter spans based on criteria
*/
@@ -406,9 +266,6 @@ export function useTraceProcessing(
// Methods
buildSpanTree,
flattenSpanTree,
findCriticalPath,
calculateMetadata,
calculateServiceBreakdown,
filterSpans,
// Computed
@@ -15,7 +15,7 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { defineComponent } from "vue";
import { mount } from "@vue/test-utils";
import { flushPromises, mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import store from "@/test/unit/helpers/store";
import useSearchBar from "./useSearchBar";
@@ -320,6 +320,24 @@ describe("useSearchBar Composable", () => {
wrapper.vm.getSavedViews();
// Loading state is set before the async call
});
it("drops traces views from the logs list", async () => {
mockSavedViewsGet.mockResolvedValue({
data: {
views: [
{ view_id: "1", view_name: "legacy" },
{ view_id: "2", view_name: "logs", view_type: "logs" },
{ view_id: "3", view_name: "traces", view_type: "traces" },
],
},
});
wrapper.vm.getSavedViews(true);
await flushPromises();
const ids = searchState().searchObj.data.savedViews.map((v: any) => v.view_id);
expect(ids).toEqual(["1", "2"]);
});
});
describe("getFunctions", () => {
+1 -1
View File
@@ -101,7 +101,7 @@ export const useSearchBar = (t: TranslateFn) => {
)
.then((views: any[]) => {
searchObj.loadingSavedView = false;
searchObj.data.savedViews = views;
searchObj.data.savedViews = views.filter((v) => v.view_type !== "traces");
})
.catch((err) => {
searchObj.loadingSavedView = false;
@@ -0,0 +1,112 @@
// 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/>.
import { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { defineComponent, h } from "vue";
import { createMemoryHistory, createRouter } from "vue-router";
import store from "@/test/unit/helpers/store";
import i18n from "@/locales";
import { queryClient } from "@/composables/query/queryClient";
const getOrgSettings = vi.fn();
vi.mock("@/services/config", () => ({
default: { get_config_full: vi.fn(() => new Promise(() => {})) },
}));
vi.mock("@openobserve/browser-rum", () => ({
openobserveRum: { setUser: vi.fn(), startView: vi.fn() },
}));
vi.mock("@/services/organizations", async (importOriginal) => {
const actual: any = await importOriginal();
return {
default: {
...actual.default,
get_organization_settings: (...args: unknown[]) => getOrgSettings(...args),
},
};
});
vi.mock("@/composables/useHomeDashboard", () => ({
useHomeDashboard: () => ({ load: vi.fn(async () => undefined) }),
}));
import MainLayout from "./MainLayout.vue";
const StubDiv = defineComponent({ setup: () => () => h("div") });
const mountLayout = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", component: StubDiv }],
});
await router.push("/");
return mount(MainLayout, {
global: {
plugins: [store, i18n, router],
stubs: {
AppHeader: StubDiv,
ONavbar: StubDiv,
O2AIChat: StubDiv,
WebinarBanner: StubDiv,
AnnouncementBanner: StubDiv,
ShortcutCheatsheet: StubDiv,
GetStarted: StubDiv,
CommunitySlackInvite: StubDiv,
ThemeSwitcher: StubDiv,
PredefinedThemes: StubDiv,
SlackIcon: StubDiv,
ManagementIcon: StubDiv,
ODialog: StubDiv,
},
},
});
};
describe("MainLayout — organization settings load", () => {
let wrapper: VueWrapper | undefined;
afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
queryClient.clear();
getOrgSettings.mockReset();
});
it.each([true, false])(
"copies red_insights_enabled=%s from the server into the store",
async (enabled) => {
getOrgSettings.mockResolvedValue({ data: { data: { red_insights_enabled: enabled } } });
wrapper = await mountLayout();
await flushPromises();
queryClient.clear();
await (
wrapper.vm as unknown as { getOrganizationSettings: () => Promise<void> }
).getOrganizationSettings();
expect(getOrgSettings).toHaveBeenCalled();
expect(store.state.organizationData.organizationSettings.red_insights_enabled).toBe(enabled);
},
);
it("defaults red_insights_enabled to false when the server omits it", async () => {
getOrgSettings.mockResolvedValue({ data: { data: {} } });
wrapper = await mountLayout();
await flushPromises();
queryClient.clear();
await (
wrapper.vm as unknown as { getOrganizationSettings: () => Promise<void> }
).getOrganizationSettings();
expect(store.state.organizationData.organizationSettings.red_insights_enabled).toBe(false);
});
});
+3
View File
@@ -1202,6 +1202,7 @@ export default defineComponent({
claim_parser_function: "",
org_storage_enabled: false,
domain_org_mappings: [],
red_insights_enabled: false,
};
try {
@@ -1246,6 +1247,8 @@ export default defineComponent({
org_storage_enabled:
orgSettings?.data?.data?.org_storage_enabled ?? defaultSettings.org_storage_enabled,
domain_org_mappings: orgSettings?.data?.data?.domain_org_mappings ?? [],
red_insights_enabled:
orgSettings?.data?.data?.red_insights_enabled ?? defaultSettings.red_insights_enabled,
});
// Load the org's home dashboard (settings/v2 KV) alongside the legacy org
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "نص الشعار المخصص",
"orgLabel": "معايير التنظيم",
"logDetails": "تفاصيل السجل",
"tracesHeading": "آثار",
"traceIdFieldName": "اسم حقل معرف التتبع",
"spanIdFieldName": "اسم حقل معرف النطاق",
"toggleIngestionLogsLabel": "تبديل سجلات الإدخال",
"usageStreamEnabledLabel": "تمكين تدفق الاستخدام",
"redInsightsEnabledLabel": "تفعيل رؤى RED التلقائية لآثار",
"redInsightsEnabledHelp": "ينشئ كاشفات شذوذ لمعدل الطلبات والأخطاء وزمن استجابة p95 لأكثر الخدمات ازدحامًا، في مجلد تنبيهات \"رؤى RED\". لا يرى هذا المجلد سوى المسؤولين حتى تتم مشاركته.",
"claimParserFunction": "محلل مطالبات SSO",
"claimParserFunctionDescription": "اختر دالة ({product1} أو {product2}) لتحليل وتحويل مطالبات SSO/LDAP إلى تصنيفات تنظيمية وأدوار",
"claimParserFunctionLabel": "اختيار الوظيفة",
@@ -10421,6 +10424,7 @@
"openInFullView": "مفتوح في العين الكامل",
"viewLogs": "عرض السجلات",
"viewLogsUnavailable": "لم أتمكن من فتح السجلات لهذه الفترة.",
"viewProfile": "عرض التوصيف",
"backToLogs": "العودة إلى السجلات",
"openInTraces": "افتح في المسارات",
"loadingStream": "جار تحميل البث...",
@@ -10481,6 +10485,7 @@
"previousMatch": "المباراة السابقة",
"nextMatch": "المباراة القادمة",
"searchInSpans": "البحث في المقاطع",
"criticalPath": "المسار الحرج",
"spansTableView": "عرض الطاولة بين المقاطع",
"comingSoon": "قريبا...",
"fetchingTrace": "أحضر أثرك.",
@@ -10622,6 +10627,7 @@
"serviceName": "الخدمة",
"status": "الحالة",
"requests": "الطلبات",
"requestsTooltip": "وحدات Span الخاصة بالخادم والمستهلك، بالإضافة إلى وحدات Span الجذرية",
"errorRate": "معدل الخطأ",
"errors": "الأخطاء",
"avgDuration": "متوسط مدة",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "معدل الخطأ"
},
"insights": {
"title": "رؤى",
"signals": {
"rate": "معدل الطلبات",
"errors": "نسبة الأخطاء",
"p95": "زمن استجابة p95"
},
"scoreAbove": "+{percent}% أعلى من العتبة",
"viewTraces": "آثار",
"viewCharts": "المخططات"
}
},
"spansTab": "الامتدادات",
"drillDown": "التعمق",
"backToResults": "العودة إلى النتائج",
"redMetrics": "مقاييس RED",
"tracesTab": "آثار",
"serviceGraphTab": "رسم الخدمة",
"serviceGraphSubtitle": "اطلع على كيفية تواصل خدماتك مع بعضها البعض — التبعيات، معدل النقل، ومعدلات الخطأ",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "أفقي",
"layoutVertical": "عمودي",
"layoutForce": "تخطيط القوة",
"savedViewStreamMissing": "لم يعد الجدول \"{stream}\" موجودًا، لذا لا يمكن تطبيق هذا العرض المحفوظ.",
"exportTraces": "آثار التصدير",
"exportTracesFailed": "فشل تصدير الآثار",
"waterfall": "الشلال",
@@ -10853,6 +10874,13 @@
"service": "الخدمة",
"duration": "المدة",
"start": "ابدأ",
"operationPercentileBand": "{band} من {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} عينة",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "إعادة إرسال الطلب {count} مرة | الطلب يعاد {count} مرة",
"resends": "إعادة الإرسال",
"spanIdTitle": "معرف المدى: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Benutzerdefinierter Logo-Text",
"orgLabel": "Organisationsparameter",
"logDetails": "Details protokollieren",
"tracesHeading": "Traces",
"traceIdFieldName": "Trace-ID-Feldname",
"spanIdFieldName": "Span-ID-Feldname",
"toggleIngestionLogsLabel": "Aufnahmeprotokolle umschalten",
"usageStreamEnabledLabel": "Nutzungsstream aktivieren",
"redInsightsEnabledLabel": "Automatische RED-Insights für Traces aktivieren",
"redInsightsEnabledHelp": "Erstellt Anomalie-Detektoren für Rate, Fehler und p95-Latenz für die am stärksten ausgelasteten Services im Warnmeldungsordner \"RED insights\". Nur Administratoren sehen diesen Ordner, bis er freigegeben wird.",
"claimParserFunction": "SSO-Forderungsparser",
"claimParserFunctionDescription": "Wählen Sie eine Funktion ({product1} oder {product2}) aus, um SSO-/LDAP-Ansprüche zu parsen und in Organisations- und Rollenzuweisungen umzuwandeln",
"claimParserFunctionLabel": "Wählen Sie die VRL-Funktion",
@@ -10421,6 +10424,7 @@
"openInFullView": "In Vollansicht öffnen",
"viewLogs": "Protokolle anzeigen",
"viewLogsUnavailable": "Logs für diesen Span konnten nicht geöffnet werden.",
"viewProfile": "Profil anzeigen",
"backToLogs": "Zurück zu Logs",
"openInTraces": "In Traces öffnen",
"loadingStream": "Stream wird geladen...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Vorheriges Spiel",
"nextMatch": "Nächstes Spiel",
"searchInSpans": "In Spans suchen",
"criticalPath": "Kritischer Pfad",
"spansTableView": "Spans-Tabellenansicht",
"comingSoon": "Kommt bald...",
"fetchingTrace": "Dein Trace wird geladen.",
@@ -10622,6 +10627,7 @@
"serviceName": "Bedienung",
"status": "Status",
"requests": "Anfragen",
"requestsTooltip": "Server- und Consumer-Spans sowie Root-Spans",
"errorRate": "Fehlerrate",
"errors": "Fehler",
"avgDuration": "Durchschn. Dauer",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Fehlerrate"
},
"insights": {
"title": "Insights",
"signals": {
"rate": "Anfragenrate",
"errors": "Fehlerquote",
"p95": "p95-Latenz"
},
"scoreAbove": "+{percent}% Score über dem Schwellenwert",
"viewTraces": "Traces",
"viewCharts": "Diagramme"
}
},
"spansTab": "Spans",
"drillDown": "Drill-down",
"backToResults": "Zurück zu den Ergebnissen",
"redMetrics": "RED-Metriken",
"tracesTab": "Traces",
"serviceGraphTab": "Service-Diagramm",
"serviceGraphSubtitle": "Sehen Sie, wie Ihre Services sich gegenseitig aufrufen — Abhängigkeiten, Durchsatz und Fehlerraten",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Horizontal",
"layoutVertical": "Vertikal",
"layoutForce": "Anordnung erzwingen",
"savedViewStreamMissing": "Der Stream \"{stream}\" existiert nicht mehr, daher kann diese gespeicherte Ansicht nicht angewendet werden.",
"exportTraces": "Traces exportieren",
"exportTracesFailed": "Traces konnten nicht exportiert werden",
"waterfall": "Wasserfall",
@@ -10853,6 +10874,13 @@
"service": "Dienst",
"duration": "Dauer",
"start": "Start",
"operationPercentileBand": "{band} von {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} Stichproben",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Anfrage erneut gesendet {count} Mal | Anfrage erneut gesendet {count} Mal",
"resends": "Erneute Sendungen",
"spanIdTitle": "Span-ID: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Custom Logo Text",
"orgLabel": "Organization Parameters",
"logDetails": "Log Details",
"tracesHeading": "Traces",
"traceIdFieldName": "Trace ID Field Name",
"spanIdFieldName": "Span ID Field Name",
"toggleIngestionLogsLabel": "Toggle Ingestion Logs",
"usageStreamEnabledLabel": "Enable Usage Stream",
"redInsightsEnabledLabel": "Enable automatic RED insights for traces",
"redInsightsEnabledHelp": "Creates rate, error and p95 latency anomaly detectors for the busiest services, in the \"RED insights\" alert folder. Only admins see that folder until it is shared.",
"claimParserFunction": "SSO Claim Parser",
"claimParserFunctionDescription": "Select a function ({product1} or {product2}) to parse and transform SSO/LDAP claims into organization and role assignments",
"claimParserFunctionLabel": "Select Function",
@@ -10421,6 +10424,7 @@
"openInFullView": "Open in full view",
"viewLogs": "View Logs",
"viewLogsUnavailable": "Could not open logs for this span.",
"viewProfile": "View profile",
"backToLogs": "Back to Logs",
"openInTraces": "Open in Traces",
"loadingStream": "Loading Stream...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Previous match",
"nextMatch": "Next match",
"searchInSpans": "Search in spans",
"criticalPath": "Critical path",
"spansTableView": "Spans Table View",
"comingSoon": "Coming soon...",
"fetchingTrace": "Fetching your trace.",
@@ -10622,6 +10627,7 @@
"serviceName": "Service",
"status": "Status",
"requests": "Requests",
"requestsTooltip": "Server and consumer spans, plus root spans",
"errorRate": "Error Rate",
"errors": "Errors",
"avgDuration": "Avg Duration",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Error Rate"
},
"insights": {
"title": "Insights",
"signals": {
"rate": "request rate",
"errors": "error ratio",
"p95": "p95 latency"
},
"scoreAbove": "+{percent}% score above threshold",
"viewTraces": "Traces",
"viewCharts": "Charts"
}
},
"spansTab": "Spans",
"drillDown": "Drill down",
"backToResults": "Back to results",
"redMetrics": "RED Metrics",
"tracesTab": "Traces",
"serviceGraphTab": "Service Graph",
"serviceGraphSubtitle": "See how your services call each other — dependencies, throughput, and error rates",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Horizontal",
"layoutVertical": "Vertical",
"layoutForce": "Force Layout",
"savedViewStreamMissing": "Stream \"{stream}\" no longer exists, so this saved view cannot be applied.",
"exportTraces": "Export Traces",
"exportTracesFailed": "Failed to export traces",
"waterfall": "Waterfall",
@@ -10853,6 +10874,13 @@
"service": "Service",
"duration": "Duration",
"start": "Start",
"operationPercentileBand": "{band} of {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} samples",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Request resent {count} time | Request resent {count} times",
"resends": "Resends",
"spanIdTitle": "Span ID: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Texto de logotipo personalizado",
"orgLabel": "Parámetros de organización",
"logDetails": "Detalles del registro",
"tracesHeading": "Trazas",
"traceIdFieldName": "Nombre del campo de ID de traza",
"spanIdFieldName": "Nombre del campo de ID de span",
"toggleIngestionLogsLabel": "Alternar registros de ingestión",
"usageStreamEnabledLabel": "Habilitar flujo de uso",
"redInsightsEnabledLabel": "Activar insights RED automáticos para las trazas",
"redInsightsEnabledHelp": "Crea detectores de anomalías de tasa, error y latencia p95 para los servicios con más actividad, en la carpeta de alertas \"RED insights\". Solo los administradores ven esa carpeta hasta que se comparta.",
"claimParserFunction": "Analizador de reclamos de SSO",
"claimParserFunctionDescription": "Seleccione una función ({product1} o {product2}) para analizar y transformar las declaraciones SSO/LDAP en asignaciones de organización y rol",
"claimParserFunctionLabel": "Seleccione la función VRL",
@@ -10421,6 +10424,7 @@
"openInFullView": "Abrir a la vista",
"viewLogs": "Ver registros",
"viewLogsUnavailable": "No se pudieron abrir los registros para este span.",
"viewProfile": "Ver perfil",
"backToLogs": "Volver a los registros",
"openInTraces": "Abrir en Trazas",
"loadingStream": "Cargando Stream...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Partido anterior",
"nextMatch": "Próximo partido",
"searchInSpans": "Buscar en spans",
"criticalPath": "Ruta crítica",
"spansTableView": "Vista de tabla de spans",
"comingSoon": "Próximamente...",
"fetchingTrace": "Obteniendo tu traza.",
@@ -10622,6 +10627,7 @@
"serviceName": "Servicio",
"status": "Estado",
"requests": "Solicitudes",
"requestsTooltip": "Spans de servidor y de consumidor, además de los spans raíz",
"errorRate": "Tasa de error",
"errors": "Errores",
"avgDuration": "Duración media",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Tasa de errores"
},
"insights": {
"title": "Insights",
"signals": {
"rate": "tasa de solicitudes",
"errors": "proporción de errores",
"p95": "latencia p95"
},
"scoreAbove": "+{percent}% de puntuación por encima del umbral",
"viewTraces": "Trazas",
"viewCharts": "Gráficos"
}
},
"spansTab": "Spans",
"drillDown": "Profundizar",
"backToResults": "Volver a los resultados",
"redMetrics": "Métricas RED",
"tracesTab": "Trazas",
"serviceGraphTab": "Gráfico de servicio",
"serviceGraphSubtitle": "Vea cómo sus servicios se comunican entre sí: dependencias, rendimiento y tasas de error",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Horizontal",
"layoutVertical": "Vertical",
"layoutForce": "Diseño de fuerza",
"savedViewStreamMissing": "El stream \"{stream}\" ya no existe, por lo que no se puede aplicar esta vista guardada.",
"exportTraces": "Exportar trazas",
"exportTracesFailed": "No se pudieron exportar las trazas",
"waterfall": "Cascada",
@@ -10853,6 +10874,13 @@
"service": "Servicio",
"duration": "Duración",
"start": "Inicio",
"operationPercentileBand": "{band} de {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} muestras",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Solicitud reenviada {count} vez | Solicitud reenviada {count} veces",
"resends": "Reenvíos",
"spanIdTitle": "ID del Span: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Texte du logo personnalisé",
"orgLabel": "Paramètres de l'organisation",
"logDetails": "Détails du journal",
"tracesHeading": "Traces",
"traceIdFieldName": "Nom du champ ID de trace",
"spanIdFieldName": "Nom du champ Span ID",
"toggleIngestionLogsLabel": "Activer/désactiver les journaux d'ingestion",
"usageStreamEnabledLabel": "Activer le flux d'utilisation",
"redInsightsEnabledLabel": "Activer les insights RED automatiques pour les traces",
"redInsightsEnabledHelp": "Crée des détecteurs d'anomalies de taux de requêtes, d'erreurs et de latence p95 pour les services les plus sollicités, dans le dossier d'alertes « RED insights ». Seuls les administrateurs voient ce dossier jusqu'à son partage.",
"claimParserFunction": "Analyseur de réclamations SSO",
"claimParserFunctionDescription": "Sélectionnez une fonction ({product1} ou {product2}) pour analyser et transformer les revendications SSO/LDAP en affectations d'organisation et de rôle",
"claimParserFunctionLabel": "Sélectionnez la fonction VRL",
@@ -10421,6 +10424,7 @@
"openInFullView": "Ouvrir en plein écran",
"viewLogs": "Afficher les journaux",
"viewLogsUnavailable": "Impossible d'ouvrir les logs pour ce span.",
"viewProfile": "Voir le profil",
"backToLogs": "Retour aux journaux",
"openInTraces": "Ouvrir dans Traces",
"loadingStream": "Chargement du flux...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Match précédent",
"nextMatch": "Prochain match",
"searchInSpans": "Rechercher dans les spans",
"criticalPath": "Chemin critique",
"spansTableView": "Vue tableau des spans",
"comingSoon": "Prochainement...",
"fetchingTrace": "Récupération de votre trace.",
@@ -10622,6 +10627,7 @@
"serviceName": "Service",
"status": "État",
"requests": "Demandes",
"requestsTooltip": "Spans serveur et consommateur, ainsi que les spans racines",
"errorRate": "Taux d'erreur",
"errors": "Erreurs",
"avgDuration": "Durée moy.",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Taux d'erreur"
},
"insights": {
"title": "Insights",
"signals": {
"rate": "taux de requêtes",
"errors": "ratio d'erreurs",
"p95": "latence p95"
},
"scoreAbove": "+{percent} % de score au-dessus du seuil",
"viewTraces": "Traces",
"viewCharts": "Graphiques"
}
},
"spansTab": "Spans",
"drillDown": "Explorer en détail",
"backToResults": "Retour aux résultats",
"redMetrics": "Métriques RED",
"tracesTab": "Traces",
"serviceGraphTab": "Graphique des services",
"serviceGraphSubtitle": "Découvrez comment vos services interagissent — dépendances, débit et taux d'erreur",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "horizontal",
"layoutVertical": "Verticale",
"layoutForce": "Disposition des forces",
"savedViewStreamMissing": "Le flux « {stream} » n'existe plus, cette vue enregistrée ne peut donc pas être appliquée.",
"exportTraces": "Exporter les traces",
"exportTracesFailed": "Échec de l'exportation des traces",
"waterfall": "Cascade",
@@ -10853,6 +10874,13 @@
"service": "Service",
"duration": "Durée",
"start": "Début",
"operationPercentileBand": "{band} de {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} échantillons",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Requête renvoyée {count} fois | Requête renvoyée {count} fois",
"resends": "Renvois",
"spanIdTitle": "ID du span : {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Testo del logo personalizzato",
"orgLabel": "Parametri dell'organizzazione",
"logDetails": "Dettagli del log",
"tracesHeading": "Tracce",
"traceIdFieldName": "Nome campo Trace ID",
"spanIdFieldName": "Nome campo Span ID",
"toggleIngestionLogsLabel": "Attiva/disattiva i log di ingestione",
"usageStreamEnabledLabel": "Abilita flusso di utilizzo",
"redInsightsEnabledLabel": "Abilita insight RED automatici per le tracce",
"redInsightsEnabledHelp": "Crea rilevatori di anomalie per tasso di richieste, errori e latenza p95 per i servizi più attivi, nella cartella di avvisi \"RED insights\". Solo gli amministratori vedono quella cartella finché non viene condivisa.",
"claimParserFunction": "Analisi dei reclami SSO",
"claimParserFunctionDescription": "Seleziona una funzione ({product1} o {product2}) per analizzare e trasformare le attestazioni SSO/LDAP in assegnazioni di organizzazione e ruolo",
"claimParserFunctionLabel": "Seleziona la funzione VRL",
@@ -10421,6 +10424,7 @@
"openInFullView": "Apri a piena vista",
"viewLogs": "Visualizza i log",
"viewLogsUnavailable": "Impossibile aprire i log per questo span.",
"viewProfile": "Visualizza profilo",
"backToLogs": "Torna ai log",
"openInTraces": "Apri in Tracce",
"loadingStream": "Caricamento dello stream...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Partita precedente",
"nextMatch": "Prossima partita",
"searchInSpans": "Cerca negli span",
"criticalPath": "Percorso critico",
"spansTableView": "Vista tabella degli span",
"comingSoon": "Presto in arrivo...",
"fetchingTrace": "Recupero della tua traccia.",
@@ -10622,6 +10627,7 @@
"serviceName": "Servizio",
"status": "Status",
"requests": "Richieste",
"requestsTooltip": "Span server e consumer, più gli span radice",
"errorRate": "Tasso di errore",
"errors": "Errori",
"avgDuration": "Durata media",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Tasso di errore"
},
"insights": {
"title": "Insight",
"signals": {
"rate": "tasso di richieste",
"errors": "rapporto di errori",
"p95": "latenza p95"
},
"scoreAbove": "+{percent}% di punteggio sopra la soglia",
"viewTraces": "Tracce",
"viewCharts": "Grafici"
}
},
"spansTab": "Span",
"drillDown": "Approfondisci",
"backToResults": "Torna ai risultati",
"redMetrics": "Metriche RED",
"tracesTab": "Tracce",
"serviceGraphTab": "Grafico dei servizi",
"serviceGraphSubtitle": "Scopri come i tuoi servizi si chiamano tra loro — dipendenze, throughput e tassi di errore",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "orizzontale",
"layoutVertical": "Verticale",
"layoutForce": "Layout delle forze",
"savedViewStreamMissing": "Lo stream \"{stream}\" non esiste più, quindi questa vista salvata non può essere applicata.",
"exportTraces": "Esporta tracce",
"exportTracesFailed": "Impossibile esportare le tracce",
"waterfall": "Cascata",
@@ -10853,6 +10874,13 @@
"service": "Servizio",
"duration": "Durata",
"start": "Inizio",
"operationPercentileBand": "{band} di {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} campioni",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Richiesta reinviata {count} volta | Richiesta reinviata {count} volte",
"resends": "Reinvii",
"spanIdTitle": "ID span: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "カスタムロゴテキスト",
"orgLabel": "組織パラメーター",
"logDetails": "ログ詳細",
"tracesHeading": "トレース",
"traceIdFieldName": "トレースIDフィールド名",
"spanIdFieldName": "スパンIDフィールド名",
"toggleIngestionLogsLabel": "取り込みログの切り替え",
"usageStreamEnabledLabel": "使用ストリームを有効にする",
"redInsightsEnabledLabel": "トレースの RED インサイトを自動的に有効にする",
"redInsightsEnabledHelp": "最も負荷の高いサービスに対して、レート、エラー、p95 レイテンシの異常検知を「RED インサイト」アラートフォルダー内に作成します。共有されるまで、そのフォルダーは管理者のみ表示できます。",
"claimParserFunction": "SSO クレームパーサー",
"claimParserFunctionDescription": "{product1} または {product2} の関数を選択して、SSO/LDAP クレームを解析および変換し、組織とロールの割り当てを行います",
"claimParserFunctionLabel": "VRL 機能の選択",
@@ -10421,6 +10424,7 @@
"openInFullView": "フルビューで開く",
"viewLogs": "ログを表示",
"viewLogsUnavailable": "このスパンのログを開けませんでした。",
"viewProfile": "プロファイルを表示",
"backToLogs": "ログに戻る",
"openInTraces": "トレースで開く",
"loadingStream": "ストリームをロード中...",
@@ -10481,6 +10485,7 @@
"previousMatch": "前の試合",
"nextMatch": "次の試合",
"searchInSpans": "スパン内を検索",
"criticalPath": "クリティカルパス",
"spansTableView": "スパンのテーブル表示",
"comingSoon": "近日公開予定...",
"fetchingTrace": "トレースを取得しています。",
@@ -10622,6 +10627,7 @@
"serviceName": "サービス",
"status": "ステータス",
"requests": "リクエスト",
"requestsTooltip": "サーバースパンとコンシューマースパン、およびルートスパン",
"errorRate": "エラー率",
"errors": "エラー",
"avgDuration": "平均所要時間",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "エラー率"
},
"insights": {
"title": "インサイト",
"signals": {
"rate": "リクエストレート",
"errors": "エラー率",
"p95": "p95 レイテンシ"
},
"scoreAbove": "しきい値を {percent}% 上回るスコア",
"viewTraces": "トレース",
"viewCharts": "チャート"
}
},
"spansTab": "スパン",
"drillDown": "ドリルダウン",
"backToResults": "結果に戻る",
"redMetrics": "RED メトリクス",
"tracesTab": "トレース",
"serviceGraphTab": "サービスグラフ",
"serviceGraphSubtitle": "サービスがどのように相互に呼び出すかを確認 — 依存関係、スループット、エラー率",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "水平",
"layoutVertical": "垂直",
"layoutForce": "フォースレイアウト",
"savedViewStreamMissing": "ストリーム \"{stream}\" は存在しないため、この保存済みビューは適用できません。",
"exportTraces": "トレースをエクスポート",
"exportTracesFailed": "トレースのエクスポートに失敗しました",
"waterfall": "ウォーターフォール",
@@ -10853,6 +10874,13 @@
"service": "サービス",
"duration": "期間",
"start": "開始",
"operationPercentileBand": "{operation} の {band}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} サンプル",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "リクエストを{count}回再送しました | リクエストを{count}回再送しました",
"resends": "再送回数",
"spanIdTitle": "スパン ID: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "사용자 지정 로고 텍스트",
"orgLabel": "조직 매개변수",
"logDetails": "로그 세부 정보",
"tracesHeading": "트레이스",
"traceIdFieldName": "트레이스 ID 필드 이름",
"spanIdFieldName": "스팬 ID 필드 이름",
"toggleIngestionLogsLabel": "통합 로그 전환",
"usageStreamEnabledLabel": "사용 스트림 활성화",
"redInsightsEnabledLabel": "트레이스에 대한 자동 RED 인사이트 활성화",
"redInsightsEnabledHelp": "가장 트래픽이 많은 서비스에 대해 요청률, 오류, p95 지연 시간 이상 감지기를 \"RED insights\" 알림 폴더에 생성합니다. 해당 폴더는 공유되기 전까지 관리자만 볼 수 있습니다.",
"claimParserFunction": "SSO 클레임 파서",
"claimParserFunctionDescription": "SSO/LDAP 클레임을 구문 분석하고 조직 및 역할 할당으로 변환할 함수({product1} 또는 {product2})를 선택하세요.",
"claimParserFunctionLabel": "VRL 함수 선택",
@@ -10421,6 +10424,7 @@
"openInFullView": "전체 화면으로 열기",
"viewLogs": "로그 보기",
"viewLogsUnavailable": "이 Span에 대한 로그를 열 수 없습니다.",
"viewProfile": "프로파일 보기",
"backToLogs": "로그로 돌아가기",
"openInTraces": "트레이스에서 열기",
"loadingStream": "스트림 로드 중...",
@@ -10481,6 +10485,7 @@
"previousMatch": "이전 경기",
"nextMatch": "다음 경기",
"searchInSpans": "Span에서 검색",
"criticalPath": "크리티컬 패스",
"spansTableView": "Span 테이블 보기",
"comingSoon": "곧 출시됩니다...",
"fetchingTrace": "트레이스를 가져오는 중입니다.",
@@ -10622,6 +10627,7 @@
"serviceName": "서비스",
"status": "상태",
"requests": "요청",
"requestsTooltip": "서버 및 컨슈머 스팬, 그리고 루트 스팬",
"errorRate": "오류율",
"errors": "오류",
"avgDuration": "평균 지속 시간",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "오류율"
},
"insights": {
"title": "인사이트",
"signals": {
"rate": "요청률",
"errors": "오류 비율",
"p95": "p95 지연 시간"
},
"scoreAbove": "임계값 초과 점수 +{percent}%",
"viewTraces": "트레이스",
"viewCharts": "차트"
}
},
"spansTab": "Span",
"drillDown": "드릴다운",
"backToResults": "결과로 돌아가기",
"redMetrics": "RED 메트릭",
"tracesTab": "트레이스",
"serviceGraphTab": "서비스 그래프",
"serviceGraphSubtitle": "서비스가 서로 호출하는 방식을 확인하세요 — 종속성, 처리량 및 오류율",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "수평형",
"layoutVertical": "수직형",
"layoutForce": "포스 레이아웃",
"savedViewStreamMissing": "스트림 \"{stream}\"이(가) 더 이상 존재하지 않으므로 이 저장된 뷰를 적용할 수 없습니다.",
"exportTraces": "트레이스 내보내기",
"exportTracesFailed": "트레이스를 내보내지 못했습니다",
"waterfall": "폭포",
@@ -10853,6 +10874,13 @@
"service": "서비스",
"duration": "지속 시간",
"start": "시작",
"operationPercentileBand": "{operation}의 {band}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · 샘플 {n}개",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "요청이 {count}회 재전송됨 | 요청이 {count}회 재전송됨",
"resends": "재전송",
"spanIdTitle": "Span ID: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Aangepaste logotekst",
"orgLabel": "Organisatieparameters",
"logDetails": "Gegevens van het logboek",
"tracesHeading": "Traces",
"traceIdFieldName": "Veldnaam trace-ID",
"spanIdFieldName": "Veldnaam span-ID",
"toggleIngestionLogsLabel": "Logboeken voor opname in- en uitschakelen",
"usageStreamEnabledLabel": "Usage Stream inschakelen",
"redInsightsEnabledLabel": "Automatische RED-inzichten voor traces inschakelen",
"redInsightsEnabledHelp": "Maakt anomaliedetectoren voor frequentie, fouten en p95-latentie aan voor de drukste services, in de alarmmap \"RED insights\". Alleen beheerders zien die map totdat deze wordt gedeeld.",
"claimParserFunction": "SSO-claimparser",
"claimParserFunctionDescription": "Selecteer een functie ({product1} of {product2}) om SSO/LDAP-claims te parseren en te transformeren naar organisatie- en roltoewijzingen",
"claimParserFunctionLabel": "Selecteer de VRL-functie",
@@ -10421,6 +10424,7 @@
"openInFullView": "In volledige weergave openen",
"viewLogs": "Logboeken bekijken",
"viewLogsUnavailable": "Kan de logboeken voor dit span niet openen.",
"viewProfile": "Profiel bekijken",
"backToLogs": "Terug naar de logboeken",
"openInTraces": "Openen in Traces",
"loadingStream": "Stream wordt geladen...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Vorige wedstrijd",
"nextMatch": "Volgende wedstrijd",
"searchInSpans": "Zoeken in spans",
"criticalPath": "Kritieke pad",
"spansTableView": "Spans-tabelweergave",
"comingSoon": "Binnenkort beschikbaar...",
"fetchingTrace": "Uw trace wordt opgehaald.",
@@ -10622,6 +10627,7 @@
"serviceName": "Service",
"status": "Status",
"requests": "Verzoeken",
"requestsTooltip": "Server- en consumerspans, plus rootspans",
"errorRate": "Foutpercentage",
"errors": "Fouten",
"avgDuration": "Gem. duur",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Foutpercentage"
},
"insights": {
"title": "Inzichten",
"signals": {
"rate": "verzoekfrequentie",
"errors": "foutratio",
"p95": "p95-latentie"
},
"scoreAbove": "+{percent}% score boven drempelwaarde",
"viewTraces": "Traces",
"viewCharts": "Grafieken"
}
},
"spansTab": "Spans",
"drillDown": "Drill-down",
"backToResults": "Terug naar resultaten",
"redMetrics": "RED-metrieken",
"tracesTab": "Traces",
"serviceGraphTab": "Servicegrafiek",
"serviceGraphSubtitle": "Bekijk hoe uw services elkaar aanroepen — afhankelijkheden, doorvoer en foutpercentages",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Horizontaal",
"layoutVertical": "Verticaal",
"layoutForce": "Indeling van de kracht",
"savedViewStreamMissing": "Stream \"{stream}\" bestaat niet meer, dus deze opgeslagen weergave kan niet worden toegepast.",
"exportTraces": "Exporteer traces",
"exportTracesFailed": "Exporteren van traces mislukt",
"waterfall": "Waterval",
@@ -10853,6 +10874,13 @@
"service": "Service",
"duration": "Duur",
"start": "Start",
"operationPercentileBand": "{band} van {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} metingen",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Verzoek {count} keer opnieuw verzonden | Verzoek {count} keer opnieuw verzonden",
"resends": "Opnieuw verzonden",
"spanIdTitle": "Span ID: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Tekst niestandardowego logo",
"orgLabel": "Parametry organizacji",
"logDetails": "Szczegóły logów",
"tracesHeading": "Trace",
"traceIdFieldName": "Nazwa pola ID śladu",
"spanIdFieldName": "Nazwa pola ID span",
"toggleIngestionLogsLabel": "Przełącz logi pozyskiwania",
"usageStreamEnabledLabel": "Włącz strumień użycia",
"redInsightsEnabledLabel": "Włącz automatyczne spostrzeżenia RED dla trace'ów",
"redInsightsEnabledHelp": "Tworzy detektory anomalii tempa, błędów i opóźnienia p95 dla najbardziej obciążonych usług, w folderze alertów „RED insights”. Ten folder widzą tylko administratorzy, dopóki nie zostanie udostępniony.",
"claimParserFunction": "Parser deklaracji SSO",
"claimParserFunctionDescription": "Wybierz funkcję ({product1} lub {product2}), aby przetwarzać i przekształcać deklaracje SSO/LDAP w przypisania organizacji i ról",
"claimParserFunctionLabel": "Wybierz funkcję",
@@ -10421,6 +10424,7 @@
"openInFullView": "Otwórz w pełnym widoku",
"viewLogs": "Pokaż logi",
"viewLogsUnavailable": "Nie można otworzyć logów dla tego spanu.",
"viewProfile": "Zobacz profil",
"backToLogs": "Powrót do logów",
"openInTraces": "Otwórz w Śladach",
"loadingStream": "Ładowanie strumienia...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Poprzednie dopasowanie",
"nextMatch": "Następne dopasowanie",
"searchInSpans": "Szukaj w spanach",
"criticalPath": "Ścieżka krytyczna",
"spansTableView": "Widok tabeli spanów",
"comingSoon": "Wkrótce...",
"fetchingTrace": "Pobieranie twojego śladu.",
@@ -10622,6 +10627,7 @@
"serviceName": "Usługa",
"status": "Status",
"requests": "Żądania",
"requestsTooltip": "Spany serwera i konsumenta oraz spany główne",
"errorRate": "Wskaźnik błędów",
"errors": "Błędy",
"avgDuration": "Średni czas trwania",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Wskaźnik błędów"
},
"insights": {
"title": "Spostrzeżenia",
"signals": {
"rate": "częstotliwość zapytań",
"errors": "odsetek błędów",
"p95": "opóźnienie p95"
},
"scoreAbove": "+{percent}% wyniku powyżej progu",
"viewTraces": "Trace",
"viewCharts": "Wykresy"
}
},
"spansTab": "Spany",
"drillDown": "Przejdź w głąb",
"backToResults": "Powrót do wyników",
"redMetrics": "Metryki RED",
"tracesTab": "Ślady",
"serviceGraphTab": "Graf usług",
"serviceGraphSubtitle": "Zobacz, jak Twoje usługi wywołują się nawzajem — zależności, przepustowość i wskaźniki błędów",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Poziomo",
"layoutVertical": "Pionowo",
"layoutForce": "Układ siłowy",
"savedViewStreamMissing": "Strumień „{stream}” już nie istnieje, więc nie można zastosować tego zapisanego widoku.",
"exportTraces": "Eksportuj ślady",
"exportTracesFailed": "Nie udało się wyeksportować śladów",
"waterfall": "Wodospad",
@@ -10853,6 +10874,13 @@
"service": "Usługa",
"duration": "Czas trwania",
"start": "Początek",
"operationPercentileBand": "{band} dla {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} próbek",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Wysłano żądanie ponownie {count} raz | Wysłano żądanie ponownie {count} razy",
"resends": "Ponowne wysłania",
"spanIdTitle": "ID spanu: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Texto de logotipo personalizado",
"orgLabel": "Parâmetros da organização",
"logDetails": "Detalhes do registro",
"tracesHeading": "Rastros",
"traceIdFieldName": "Nome do campo Trace ID",
"spanIdFieldName": "Nome do campo Span ID",
"toggleIngestionLogsLabel": "Alternar registros de ingestão",
"usageStreamEnabledLabel": "Ativar fluxo de uso",
"redInsightsEnabledLabel": "Ativar insights RED automáticos para rastros",
"redInsightsEnabledHelp": "Cria detectores de anomalia de taxa, erros e latência p95 para os serviços mais movimentados, na pasta de alertas \"RED insights\". Apenas administradores veem essa pasta até que ela seja compartilhada.",
"claimParserFunction": "Analisador de declarações de SSO",
"claimParserFunctionDescription": "Selecione uma função ({product1} ou {product2}) para analisar e transformar declarações de SSO/LDAP em atribuições de organização e função",
"claimParserFunctionLabel": "Selecione a função VRL",
@@ -10421,6 +10424,7 @@
"openInFullView": "Abrir à vista completa",
"viewLogs": "Exibir registros",
"viewLogsUnavailable": "Não foi possível abrir os registros para este span.",
"viewProfile": "Ver perfil",
"backToLogs": "Voltar aos registros",
"openInTraces": "Abrir em Rastreamentos",
"loadingStream": "Carregando Stream...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Partida anterior",
"nextMatch": "Próxima partida",
"searchInSpans": "Pesquisar em spans",
"criticalPath": "Caminho crítico",
"spansTableView": "Exibição de tabela de spans",
"comingSoon": "Em breve...",
"fetchingTrace": "Buscando seu rastreamento.",
@@ -10622,6 +10627,7 @@
"serviceName": "Serviço",
"status": "Status",
"requests": "Solicitações",
"requestsTooltip": "Spans de servidor e consumidor, além de spans raiz",
"errorRate": "Taxa de erro",
"errors": "Erros",
"avgDuration": "Duração Média",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Taxa de Erros"
},
"insights": {
"title": "Insights",
"signals": {
"rate": "taxa de requisições",
"errors": "proporção de erros",
"p95": "latência p95"
},
"scoreAbove": "+{percent}% da pontuação acima do limite",
"viewTraces": "Rastros",
"viewCharts": "Gráficos"
}
},
"spansTab": "Spans",
"drillDown": "Detalhar",
"backToResults": "Voltar aos resultados",
"redMetrics": "Métricas RED",
"tracesTab": "Rastreamentos",
"serviceGraphTab": "Gráfico de serviços",
"serviceGraphSubtitle": "Veja como seus serviços se comunicam — dependências, throughput e taxas de erro",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Horizontal",
"layoutVertical": "Vertical",
"layoutForce": "Layout de força",
"savedViewStreamMissing": "O stream \"{stream}\" não existe mais, portanto esta visualização salva não pode ser aplicada.",
"exportTraces": "Exportar rastreamentos",
"exportTracesFailed": "Falha ao exportar rastreamentos",
"waterfall": "Cachoeira",
@@ -10853,6 +10874,13 @@
"service": "Serviço",
"duration": "Duração",
"start": "Início",
"operationPercentileBand": "{band} de {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} amostras",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Requisição reenviada {count} vez | Requisição reenviada {count} vezes",
"resends": "Reenvios",
"spanIdTitle": "ID do Span: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Текст пользовательского логотипа",
"orgLabel": "Параметры организации",
"logDetails": "Детали логов",
"tracesHeading": "Трейсы",
"traceIdFieldName": "Имя поля Trace ID",
"spanIdFieldName": "Имя поля Span ID",
"toggleIngestionLogsLabel": "Переключить логи приема",
"usageStreamEnabledLabel": "Включить поток использования",
"redInsightsEnabledLabel": "Включить автоматические RED-инсайты для трейсов",
"redInsightsEnabledHelp": "Создаёт детекторы аномалий частоты запросов, ошибок и задержки p95 для самых нагруженных сервисов в папке оповещений «RED insights». Пока папка не предоставлена другим, её видят только администраторы.",
"claimParserFunction": "Парсер утверждений SSO",
"claimParserFunctionDescription": "Выберите функцию ({product1} или {product2}) для разбора и преобразования утверждений SSO/LDAP в назначения организации и ролей.",
"claimParserFunctionLabel": "Выбрать функцию",
@@ -10421,6 +10424,7 @@
"openInFullView": "Открыть в полном виде",
"viewLogs": "Просмотр логов",
"viewLogsUnavailable": "Не удалось открыть логи для этого спана.",
"viewProfile": "Просмотреть профиль",
"backToLogs": "Назад к логам",
"openInTraces": "Открыть в трейсах",
"loadingStream": "Загрузка потока...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Предыдущее совпадение",
"nextMatch": "Следующее совпадение",
"searchInSpans": "Искать в спанах",
"criticalPath": "Критический путь",
"spansTableView": "Табличное представление спанов",
"comingSoon": "Скоро...",
"fetchingTrace": "Загрузка вашего трейса.",
@@ -10622,6 +10627,7 @@
"serviceName": "Сервис",
"status": "Статус",
"requests": "Запросы",
"requestsTooltip": "Серверные спаны и спаны потребителей, а также корневые спаны",
"errorRate": "Частота ошибок",
"errors": "Ошибки",
"avgDuration": "Средняя длительность",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Частота ошибок"
},
"insights": {
"title": "Инсайты",
"signals": {
"rate": "частота запросов",
"errors": "доля ошибок",
"p95": "задержка p95"
},
"scoreAbove": "+{percent}% — оценка выше порога",
"viewTraces": "Трейсы",
"viewCharts": "Графики"
}
},
"spansTab": "Спаны",
"drillDown": "Детализация",
"backToResults": "Назад к результатам",
"redMetrics": "Метрики RED",
"tracesTab": "Трейсы",
"serviceGraphTab": "Граф сервисов",
"serviceGraphSubtitle": "Посмотрите, как ваши сервисы взаимодействуют друг с другом — зависимости, пропускная способность и уровень ошибок",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Горизонтально",
"layoutVertical": "Вертикально",
"layoutForce": "Принудительная компоновка",
"savedViewStreamMissing": "Поток «{stream}» больше не существует, поэтому это сохранённое представление нельзя применить.",
"exportTraces": "Экспорт трейсов",
"exportTracesFailed": "Не удалось экспортировать трейсы",
"waterfall": "Водопад",
@@ -10853,6 +10874,13 @@
"service": "Сервис",
"duration": "Длительность",
"start": "Начало",
"operationPercentileBand": "{band} для {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} замеров",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Запрос повторно отправлен {count} раз | Запрос повторно отправлен {count} раз",
"resends": "Повторы",
"spanIdTitle": "ID спана: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Özel Logo Metni",
"orgLabel": "Organizasyon Parametreleri",
"logDetails": "Günlük Ayrıntıları",
"tracesHeading": "İzler",
"traceIdFieldName": "İz Kimliği Alan Adı",
"spanIdFieldName": "Span Kimliği Alan Adı",
"toggleIngestionLogsLabel": "Yutma Günlüklerini Değiştir",
"usageStreamEnabledLabel": "Kullanım Akışını Etkinleştir",
"redInsightsEnabledLabel": "İzler için otomatik RED içgörülerini etkinleştir",
"redInsightsEnabledHelp": "En yoğun hizmetler için \"RED içgörüleri\" uyarı klasöründe hız, hata ve p95 gecikme anomalisi dedektörleri oluşturur. Paylaşılana kadar o klasörü yalnızca yöneticiler görebilir.",
"claimParserFunction": "SSO Talep Ayrıştırıcısı",
"claimParserFunctionDescription": "SSO/LDAP iddialarını organizasyon ve rol atamalarına ayrıştırmak ve dönüştürmek için bir işlev seçin ({product1} veya {product2})",
"claimParserFunctionLabel": "VRL Fonksiyonunu Seçin",
@@ -10421,6 +10424,7 @@
"openInFullView": "Tam görünümde aç",
"viewLogs": "Günlükleri Görüntüle",
"viewLogsUnavailable": "Bu Span için günlükler açılamadı.",
"viewProfile": "Profili görüntüle",
"backToLogs": "Günlüklere Dön",
"openInTraces": "İzlerde Aç",
"loadingStream": "Akış Yükleniyor...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Önceki maç",
"nextMatch": "Sonraki maç",
"searchInSpans": "Spanlarda ara",
"criticalPath": "Kritik yol",
"spansTableView": "Spanlar Tablo Görünümü",
"comingSoon": "Yakında...",
"fetchingTrace": "İziniz getiriliyor.",
@@ -10622,6 +10627,7 @@
"serviceName": "Hizmet",
"status": "Durum",
"requests": "İstekler",
"requestsTooltip": "Sunucu ve tüketici span'leri, ayrıca kök span'ler.",
"errorRate": "Hata Oranı",
"errors": "Hatalar",
"avgDuration": "Ort. Süre",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Hata Oranı"
},
"insights": {
"title": "İçgörüler",
"signals": {
"rate": "istek hızı",
"errors": "hata oranı",
"p95": "p95 gecikme"
},
"scoreAbove": "Eşik üstü +{percent}% puan",
"viewTraces": "İzler",
"viewCharts": "Grafikler"
}
},
"spansTab": "Spanlar",
"drillDown": "Ayrıntıya in",
"backToResults": "Sonuçlara dön",
"redMetrics": "RED Metrikleri",
"tracesTab": "İzler",
"serviceGraphTab": "Servis Grafiği",
"serviceGraphSubtitle": "Hizmetlerinizin birbirini nasıl çağırdığını görün — bağımlılıklar, işlem hacmi ve hata oranları",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Yatay",
"layoutVertical": "Dikey",
"layoutForce": "Kuvvet Düzeni",
"savedViewStreamMissing": "\"{stream}\" akışı artık mevcut değil, bu nedenle bu kayıtlı görünüm uygulanamaz.",
"exportTraces": "İzleri Dışa Aktar",
"exportTracesFailed": "İzler dışa aktarılamadı",
"waterfall": "Şelale",
@@ -10853,6 +10874,13 @@
"service": "Hizmet",
"duration": "Süre",
"start": "Başlangıç",
"operationPercentileBand": "{operation} için {band}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} örnek",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "İstek {count} kez yeniden gönderildi | İstek {count} kez yeniden gönderildi",
"resends": "Yeniden gönderimler",
"spanIdTitle": "Span Kimliği: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "Văn bản logo tùy chỉnh",
"orgLabel": "Tham số tổ chức",
"logDetails": "Chi tiết nhật ký",
"tracesHeading": "Trace",
"traceIdFieldName": "Tên trường Trace ID",
"spanIdFieldName": "Tên trường Span ID",
"toggleIngestionLogsLabel": "Bật/tắt nhật ký nhập",
"usageStreamEnabledLabel": "Bật luồng sử dụng",
"redInsightsEnabledLabel": "Bật thông tin chi tiết RED tự động cho trace",
"redInsightsEnabledHelp": "Tạo bộ phát hiện bất thường về tốc độ yêu cầu, lỗi và độ trễ p95 cho các dịch vụ bận rộn nhất, trong thư mục cảnh báo \"RED insights\". Chỉ quản trị viên nhìn thấy thư mục đó cho đến khi nó được chia sẻ.",
"claimParserFunction": "Trình phân tích xác nhận SSO",
"claimParserFunctionDescription": "Chọn một hàm ({product1} hoặc {product2}) để phân tích và chuyển đổi các claim SSO/LDAP thành các phân công tổ chức và vai trò",
"claimParserFunctionLabel": "Chọn hàm",
@@ -10421,6 +10424,7 @@
"openInFullView": "Mở toàn màn hình",
"viewLogs": "Xem Nhật ký",
"viewLogsUnavailable": "Không thể mở nhật ký cho span này.",
"viewProfile": "Xem profile",
"backToLogs": "Quay lại Nhật ký",
"openInTraces": "Mở trong Trace",
"loadingStream": "Đang tải Luồng...",
@@ -10481,6 +10485,7 @@
"previousMatch": "Kết quả trước",
"nextMatch": "Kết quả tiếp theo",
"searchInSpans": "Tìm kiếm trong span",
"criticalPath": "Đường tới hạn",
"spansTableView": "Chế độ xem bảng span",
"comingSoon": "Sắp ra mắt...",
"fetchingTrace": "Đang tải trace của bạn.",
@@ -10622,6 +10627,7 @@
"serviceName": "Dịch vụ",
"status": "Trạng thái",
"requests": "Yêu cầu",
"requestsTooltip": "Span máy chủ và span consumer, cùng với span gốc",
"errorRate": "Tỷ lệ lỗi",
"errors": "Lỗi",
"avgDuration": "Thời gian TB",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "Tỷ lệ lỗi"
},
"insights": {
"title": "Thông tin chi tiết",
"signals": {
"rate": "tốc độ yêu cầu",
"errors": "tỷ lệ lỗi",
"p95": "độ trễ p95"
},
"scoreAbove": "+{percent}% điểm số vượt ngưỡng",
"viewTraces": "Trace",
"viewCharts": "Biểu đồ"
}
},
"spansTab": "Span",
"drillDown": "Đi sâu chi tiết",
"backToResults": "Quay lại kết quả",
"redMetrics": "Chỉ số RED",
"tracesTab": "Trace",
"serviceGraphTab": "Biểu đồ dịch vụ",
"serviceGraphSubtitle": "Xem cách các dịch vụ của bạn gọi nhau — phụ thuộc, thông lượng và tỷ lệ lỗi",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "Ngang",
"layoutVertical": "Dọc",
"layoutForce": "Bố cục lực",
"savedViewStreamMissing": "Stream \"{stream}\" không còn tồn tại, nên không thể áp dụng chế độ xem đã lưu này.",
"exportTraces": "Xuất trace",
"exportTracesFailed": "Xuất trace thất bại",
"waterfall": "Thác nước",
@@ -10853,6 +10874,13 @@
"service": "Dịch vụ",
"duration": "Thời lượng",
"start": "Bắt đầu",
"operationPercentileBand": "{band} của {operation}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} mẫu",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "Đã gửi lại yêu cầu {count} lần | Đã gửi lại yêu cầu {count} lần",
"resends": "Số lần gửi lại",
"spanIdTitle": "Span ID: {id}",
+28
View File
@@ -9625,10 +9625,13 @@
"customLogoText": "自定义Logo文本",
"orgLabel": "组名",
"logDetails": "日志详情",
"tracesHeading": "追踪",
"traceIdFieldName": "TraceID 字段名称",
"spanIdFieldName": "SpanID 字段名称",
"toggleIngestionLogsLabel": "切换写入日志",
"usageStreamEnabledLabel": "启用使用流",
"redInsightsEnabledLabel": "为追踪启用自动 RED 洞察",
"redInsightsEnabledHelp": "为最繁忙的服务创建请求速率、错误和 p95 延迟异常检测器,存放在 “RED insights” 告警文件夹中。在共享之前,只有管理员可以看到该文件夹。",
"claimParserFunction": "SSO 索赔解析器",
"claimParserFunctionDescription": "选择一个函数({product1} 或 {product2})来解析并将 SSO/LDAP 声明转换为组织和角色分配",
"claimParserFunctionLabel": "选择 VRL 函数",
@@ -10421,6 +10424,7 @@
"openInFullView": "以全视图打开",
"viewLogs": "查看日志",
"viewLogsUnavailable": "无法打开此 Span 的日志。",
"viewProfile": "查看性能分析",
"backToLogs": "返回日志",
"openInTraces": "在追踪中打开",
"loadingStream": "正在加载数据流...",
@@ -10481,6 +10485,7 @@
"previousMatch": "上一个匹配项",
"nextMatch": "下一个匹配项",
"searchInSpans": "在 Span 中搜索",
"criticalPath": "关键路径",
"spansTableView": "Span 表格视图",
"comingSoon": "即将推出...",
"fetchingTrace": "正在获取您的追踪。",
@@ -10622,6 +10627,7 @@
"serviceName": "服务",
"status": "状态",
"requests": "请求",
"requestsTooltip": "服务端和消费者 Span,以及根 Span",
"errorRate": "错误率",
"errors": "错误",
"avgDuration": "平均时长",
@@ -10648,9 +10654,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "错误率"
},
"insights": {
"title": "洞察",
"signals": {
"rate": "请求速率",
"errors": "错误率",
"p95": "p95 延迟"
},
"scoreAbove": "得分超出阈值 +{percent}%",
"viewTraces": "追踪",
"viewCharts": "图表"
}
},
"spansTab": "Span",
"drillDown": "下钻",
"backToResults": "返回结果",
"redMetrics": "RED 指标",
"tracesTab": "追踪",
"serviceGraphTab": "服务图",
"serviceGraphSubtitle": "查看您的服务如何相互调用 — 依赖关系、吞吐量和错误率",
@@ -10660,6 +10680,7 @@
"layoutHorizontal": "水平",
"layoutVertical": "垂直",
"layoutForce": "原力布局",
"savedViewStreamMissing": "数据流 “{stream}” 已不存在,因此无法应用此保存的视图。",
"exportTraces": "导出追踪",
"exportTracesFailed": "导出追踪失败",
"waterfall": "瀑布",
@@ -10853,6 +10874,13 @@
"service": "服务",
"duration": "持续时间",
"start": "开始",
"operationPercentileBand": "{operation} 的 {band}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} 个样本",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "请求已重发 {count} 次 | 请求已重发 {count} 次",
"resends": "重发",
"spanIdTitle": "Span ID: {id}",
+28
View File
@@ -9620,10 +9620,13 @@
"customLogoText": "自訂 Logo 文字",
"orgLabel": "組織參數",
"logDetails": "日誌詳細資訊",
"tracesHeading": "追蹤",
"traceIdFieldName": "Trace ID 欄位名稱",
"spanIdFieldName": "Span ID 欄位名稱",
"toggleIngestionLogsLabel": "切換攝取日誌",
"usageStreamEnabledLabel": "啟用使用量串流",
"redInsightsEnabledLabel": "為追蹤啟用自動 RED 洞察",
"redInsightsEnabledHelp": "為最繁忙的服務建立速率、錯誤與 p95 延遲異常偵測器,並置於「RED insights」警示資料夾中。在共用之前,只有管理員能看到該資料夾。",
"claimParserFunction": "SSO Claim 解析器",
"claimParserFunctionDescription": "選擇一個函式({product1} 或 {product2})來解析並轉換 SSO/LDAP 宣告為組織和角色指派",
"claimParserFunctionLabel": "選擇函式",
@@ -10416,6 +10419,7 @@
"openInFullView": "在完整檢視中開啟",
"viewLogs": "檢視日誌",
"viewLogsUnavailable": "無法開啟此 span 的日誌。",
"viewProfile": "檢視效能剖析",
"backToLogs": "返回日誌",
"openInTraces": "在追蹤中開啟",
"loadingStream": "載入串流中...",
@@ -10476,6 +10480,7 @@
"previousMatch": "上一個符合",
"nextMatch": "下一個符合",
"searchInSpans": "在 spans 中搜尋",
"criticalPath": "關鍵路徑",
"spansTableView": "Spans 表格檢視",
"comingSoon": "即將推出...",
"fetchingTrace": "正在擷取追蹤。",
@@ -10617,6 +10622,7 @@
"serviceName": "服務",
"status": "狀態",
"requests": "請求數",
"requestsTooltip": "伺服器與消費者 Span,以及根 Span",
"errorRate": "錯誤率",
"errors": "錯誤",
"avgDuration": "平均延遲",
@@ -10643,9 +10649,23 @@
"unhealthyTooltip": "{count} {status}",
"legend": {
"title": "錯誤率"
},
"insights": {
"title": "洞察",
"signals": {
"rate": "請求速率",
"errors": "錯誤比率",
"p95": "p95 延遲"
},
"scoreAbove": "分數高於閾值 +{percent}%",
"viewTraces": "追蹤",
"viewCharts": "圖表"
}
},
"spansTab": "Span",
"drillDown": "深入探查",
"backToResults": "返回結果",
"redMetrics": "RED 指標",
"tracesTab": "追蹤",
"serviceGraphTab": "服務圖",
"serviceGraphSubtitle": "查看您的服務如何互相呼叫 — 相依性、輸送量和錯誤率",
@@ -10655,6 +10675,7 @@
"layoutHorizontal": "水平",
"layoutVertical": "垂直",
"layoutForce": "力導向版面配置",
"savedViewStreamMissing": "串流「{stream}」已不存在,因此無法套用此已儲存的檢視。",
"exportTraces": "匯出追蹤",
"exportTracesFailed": "匯出追蹤失敗",
"waterfall": "瀑布圖",
@@ -10848,6 +10869,13 @@
"service": "服務",
"duration": "持續時間",
"start": "開始",
"operationPercentileBand": "{operation} 的 {band}",
"operationPercentilesTooltip": "p50 {p50} · p75 {p75} · p90 {p90} · p99 {p99} · {n} 個樣本",
"operationBandBelowP50": "< p50",
"operationBandP50ToP75": "p50–p75",
"operationBandP75ToP90": "p75–p90",
"operationBandP90ToP99": "p90–p99",
"operationBandAboveP99": "> p99",
"requestResent": "請求已重新傳送 {count} 次 | 請求已重新傳送 {count} 次",
"resends": "重新傳送",
"spanIdTitle": "Span ID:{id}",
+1
View File
@@ -4165,6 +4165,7 @@ export default defineComponent({
const viewObj: any = {
data: getSearchObj(),
view_name: viewName,
view_type: "logs",
};
return createSavedView
+289 -4
View File
@@ -1,6 +1,6 @@
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick } from "vue";
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick, reactive } from "vue";
import Profiles from "@/plugins/profiles/Index.vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
@@ -27,7 +27,8 @@ vi.mock("@/services/stream", () => ({
},
}));
vi.mock("@/services/profiles", () => ({
vi.mock("@/services/profiles", async (importOriginal) => ({
...(await importOriginal<typeof import("@/services/profiles")>()),
default: {
meta: mocks.meta,
series: mocks.series,
@@ -36,6 +37,12 @@ vi.mock("@/services/profiles", () => ({
},
}));
const route = reactive({ name: "profiles", query: {} as Record<string, string> });
vi.mock("vue-router", () => ({
useRoute: () => route,
}));
vi.mock("@/utils/date", () => ({
getConsumableRelativeTime: mocks.getConsumableRelativeTime,
}));
@@ -88,6 +95,9 @@ const mergeResponse = {
},
};
// The route is shared module state, so a page left mounted would re-seed on another test's route change.
enableAutoUnmount(afterEach);
describe("Profiles page", () => {
const orgIdentifier = store.state.selectedOrganization.identifier as string;
@@ -143,6 +153,7 @@ describe("Profiles page", () => {
beforeEach(() => {
vi.clearAllMocks();
route.query = {};
mocks.nowMs = 1_700_000_000_000;
mocks.getConsumableRelativeTime.mockImplementation(() => {
const end = mocks.nowMs;
@@ -405,4 +416,278 @@ describe("Profiles page", () => {
.filter(Boolean);
expect(namesAfter[0]).toBe("fn-c");
});
describe("seeded from the route", () => {
const FROM = 1_700_000_000_000_000;
const TO = 1_700_000_120_000_000;
const seedQuery = {
stream: "profiles-b",
from: String(FROM),
to: String(TO),
service_name: "service-b",
profile_type: "cpu",
profile_unit: "nanoseconds",
filters: "trace_id=t%2C1,span_id=s1",
view: "flame",
};
const seededFilters = [
{ key: "trace_id", op: "=", value: "t,1" },
{ key: "span_id", op: "=", value: "s1" },
];
// Like the real picker, it re-emits its model on mount.
const emittingPicker = {
props: ["modelValue"],
emits: ["update:modelValue"],
mounted(this: { modelValue: object; $emit: (e: string, v: object) => void }) {
this.$emit("update:modelValue", { ...this.modelValue });
},
template: "<div />",
};
const mountSeeded = () =>
mount(Profiles, {
global: {
plugins: [i18n],
provide: { store },
stubs: { ...pageStubs, DateTimePickerDashboard: emittingPicker },
},
});
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
};
beforeEach(() => {
route.query = { ...seedQuery };
mocks.meta.mockResolvedValue({
data: {
...profileMetaResponse.data,
services: ["service-a", "service-b"],
profile_types: [
{ type: "alloc_space", unit: "bytes" },
{ type: "cpu", unit: "nanoseconds" },
],
},
});
});
it("seeds stream, service, type, filters and the flame view through the org watcher's initPage", async () => {
const wrapper = mountSeeded();
await flushPromises();
expect(
(wrapper.find('[data-test="profiles-stream-select"] select').element as HTMLSelectElement)
.value,
).toBe("profiles-b");
expect(mocks.merge).toHaveBeenCalledWith(
orgIdentifier,
"profiles-b",
expect.objectContaining({
start_time: FROM,
end_time: TO,
service_name: "service-b",
profile_type: "cpu",
profile_unit: "nanoseconds",
filters: seededFilters,
}),
);
expect(wrapper.find('[data-test="profiles-filter-chip-trace_id"]').exists()).toBe(true);
expect(wrapper.find('[data-test="profiles-filter-chip-span_id"]').exists()).toBe(true);
expect(wrapper.find('[data-test="profiles-view-flame"]').attributes("variant")).toBe(
"primary",
);
});
it("loads metadata for the seeded window", async () => {
mountSeeded();
await flushPromises();
expect(mocks.meta).toHaveBeenCalledWith(orgIdentifier, "profiles-b", {
start_time: FROM,
end_time: TO,
});
});
it("loads metadata once and queries once, even with delayed responses", async () => {
const names = deferred<typeof streamResponse>();
const meta = deferred<unknown>();
mocks.nameList.mockReturnValueOnce(names.promise);
mocks.meta.mockReturnValueOnce(meta.promise);
const wrapper = mountSeeded();
await flushPromises();
names.resolve(streamResponse);
await flushPromises();
meta.resolve({
data: {
...profileMetaResponse.data,
services: ["service-a", "service-b"],
profile_types: [{ type: "cpu", unit: "nanoseconds" }],
},
});
await flushPromises();
expect(mocks.meta).toHaveBeenCalledTimes(1);
expect(mocks.series).toHaveBeenCalledTimes(1);
expect(mocks.merge).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-test="profiles-filter-chip-trace_id"]').exists()).toBe(true);
});
it("re-seeds when the route query changes", async () => {
mountSeeded();
await flushPromises();
mocks.merge.mockClear();
route.query = { ...seedQuery, stream: "profiles-a", filters: "trace_id=t2,span_id=s2" };
await flushPromises();
expect(mocks.merge).toHaveBeenCalledTimes(1);
expect(mocks.merge).toHaveBeenCalledWith(
orgIdentifier,
"profiles-a",
expect.objectContaining({
filters: [
{ key: "trace_id", op: "=", value: "t2" },
{ key: "span_id", op: "=", value: "s2" },
],
}),
);
});
it("applies only the latest seed when the route changes while an earlier seed is loading", async () => {
const names = deferred<typeof streamResponse>();
mocks.nameList.mockReturnValueOnce(names.promise);
mountSeeded();
await flushPromises();
route.query = { ...seedQuery, stream: "profiles-a", filters: "trace_id=t2,span_id=s2" };
await flushPromises();
names.resolve(streamResponse);
await flushPromises();
expect(mocks.series).toHaveBeenCalledTimes(1);
expect(mocks.merge).toHaveBeenCalledTimes(1);
expect(mocks.merge).toHaveBeenCalledWith(
orgIdentifier,
"profiles-a",
expect.objectContaining({
filters: [
{ key: "trace_id", op: "=", value: "t2" },
{ key: "span_id", op: "=", value: "s2" },
],
}),
);
});
it("returns to the default stream and 15-minute window when the seed is dropped", async () => {
const wrapper = mountSeeded();
await flushPromises();
mocks.meta.mockClear();
mocks.merge.mockClear();
route.query = {};
await flushPromises();
const window = {
start_time: (mocks.nowMs - 15 * 60 * 1000) * 1000,
end_time: mocks.nowMs * 1000,
};
expect(
(wrapper.find('[data-test="profiles-stream-select"] select').element as HTMLSelectElement)
.value,
).toBe("profiles-a");
expect(mocks.meta).toHaveBeenCalledTimes(1);
expect(mocks.meta).toHaveBeenCalledWith(orgIdentifier, "profiles-a", window);
expect(mocks.merge).toHaveBeenCalledTimes(1);
expect(mocks.merge).toHaveBeenCalledWith(
orgIdentifier,
"profiles-a",
expect.objectContaining({ ...window, filters: [] }),
);
});
it("keeps the default first stream, 15-minute window and top view without params", async () => {
route.query = {};
const wrapper = mountSeeded();
await flushPromises();
expect(mocks.meta.mock.calls[0][1]).toBe("profiles-a");
expect(mocks.meta.mock.calls[0][2]).toEqual({
start_time: (mocks.nowMs - 15 * 60 * 1000) * 1000,
end_time: mocks.nowMs * 1000,
});
expect(wrapper.find('[data-test="profiles-applied-filters"]').exists()).toBe(false);
expect(wrapper.find('[data-test="profiles-view-top"]').attributes("variant")).toBe("primary");
});
});
describe("superseded by an org switch", () => {
const originalOrg = { ...store.state.selectedOrganization };
const otherOrg = { ...originalOrg, id: 160, identifier: "other-org" };
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
};
const selectedStreamValue = (wrapper: ReturnType<typeof mountPage>) =>
(wrapper.find('[data-test="profiles-stream-select"] select').element as HTMLSelectElement)
.value;
afterEach(() => {
store.dispatch("setSelectedOrganization", originalOrg);
});
it("drops the older org's merge when it lands after the switch", async () => {
const oldMerge = deferred<ReturnType<typeof mergeFor>>();
mocks.merge.mockReturnValue(oldMerge.promise);
const wrapper = mountPage();
await flushPromises();
expect(mocks.merge).toHaveBeenCalledWith(orgIdentifier, "profiles-a", expect.anything());
const newNames = deferred<typeof streamResponse>();
mocks.nameList.mockReturnValueOnce(newNames.promise);
store.dispatch("setSelectedOrganization", otherOrg);
await flushPromises();
oldMerge.resolve(mergeFor("fn-old-org"));
await flushPromises();
expect(wrapper.text()).not.toContain("fn-old-org");
mocks.merge.mockResolvedValue(mergeFor("fn-new-org"));
newNames.resolve(streamResponse);
await flushPromises();
expect(mocks.merge).toHaveBeenLastCalledWith("other-org", "profiles-a", expect.anything());
expect(wrapper.text()).toContain("fn-new-org");
expect(wrapper.text()).not.toContain("fn-old-org");
});
it("keeps the newer org's stream when the older stream list resolves last", async () => {
const oldNames = deferred<typeof streamResponse>();
mocks.nameList.mockReturnValueOnce(oldNames.promise);
const wrapper = mountPage();
await flushPromises();
mocks.nameList.mockResolvedValueOnce({ data: { list: [{ name: "profiles-c" }] } });
store.dispatch("setSelectedOrganization", otherOrg);
await flushPromises();
expect(selectedStreamValue(wrapper)).toBe("profiles-c");
oldNames.resolve(streamResponse);
await flushPromises();
expect(selectedStreamValue(wrapper)).toBe("profiles-c");
expect(wrapper.find('[data-test="profiles-stream-select"]').text()).not.toContain(
"profiles-a",
);
expect(new Set(mocks.meta.mock.calls.map((call) => call[1]))).toEqual(
new Set(["profiles-c"]),
);
expect(new Set(mocks.merge.mock.calls.map((call) => `${call[0]}/${call[1]}`))).toEqual(
new Set(["other-org/profiles-c"]),
);
});
});
});
+92 -11
View File
@@ -395,6 +395,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from "vue";
import { useStore } from "vuex";
import { useRoute, type LocationQuery } from "vue-router";
import DateTimePickerDashboard from "@/components/DateTimePickerDashboard.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OContent from "@/lib/core/Content/OContent.vue";
@@ -408,6 +409,7 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import CommonFlameGraph from "@/components/common/FlameGraphView.vue";
import streamService from "@/services/stream";
import profilesService, {
parseProfileFilters,
type ProfileFilter,
type ProfilesMergeResponse,
type ProfilesQueryBody,
@@ -433,16 +435,29 @@ type StackRow = {
total: number;
hasChildren: boolean;
};
type RouteSeed = {
stream: string;
range: { startTime: number; endTime: number } | null;
service: string | null;
profileType: string | null;
filters: ProfileFilter[];
view: ProfileView | null;
};
const PROFILE_VIEWS: ProfileView[] = ["top", "flame", "tree"];
const { t } = useI18nTyped();
const store = useStore();
const route = useRoute();
const pageRouteName = route.name;
const dateState = ref({
const defaultDateState = () => ({
startTime: 0,
endTime: 0,
relativeTimePeriod: "15m",
valueType: "relative",
});
const dateState = ref(defaultDateState());
const streamOptions = ref<SelectOption[]>([]);
const selectedStream = ref<string | null>(null);
const selectedService = ref<string | null>(null);
@@ -471,10 +486,16 @@ const errorMessage = ref("");
let metaRequestSeq = 0;
let queryRequestSeq = 0;
let tagValuesRequestSeq = 0;
let initRunSeq = 0;
// Holds the stream and date watchers off while a route seed is applied or dropped, so they cannot reset it or reload.
let seeding = false;
let lastRunSeeded = false;
const orgIdentifier = computed(
() => store.state.selectedOrganization?.identifier as string | undefined,
);
// A newer initPage (org or route change) supersedes every request started before it.
const isStaleRun = (run: number, org: string) => run !== initRunSeq || org !== orgIdentifier.value;
const serviceOptions = computed<SelectOption[]>(() =>
(metaResponse.value?.services ?? []).map((value) => ({ label: raw(value), value })),
@@ -861,7 +882,9 @@ const loadTagValues = async () => {
const loadStreams = async () => {
const org = orgIdentifier.value;
if (!org) return;
const run = initRunSeq;
const response = await streamService.nameList(org, "profiles", false);
if (isStaleRun(run, org)) return;
const list = Array.isArray(response?.data?.list) ? response.data.list : [];
streamOptions.value = list
.map((item: Record<string, unknown>) => String(item.name ?? item.stream_name ?? ""))
@@ -885,12 +908,13 @@ const loadMeta = async () => {
const range = resolveTimeRange();
if (!org || !stream || !range) return;
const requestSeq = ++metaRequestSeq;
const run = initRunSeq;
try {
const response = await profilesService.meta(org, stream, {
start_time: range.startTime,
end_time: range.endTime,
});
if (requestSeq !== metaRequestSeq) return;
if (requestSeq !== metaRequestSeq || isStaleRun(run, org)) return;
metaResponse.value = response.data;
const firstService = serviceOptions.value[0]?.value ?? null;
const firstProfileType = profileTypeOptions.value[0]?.value ?? null;
@@ -914,7 +938,7 @@ const loadMeta = async () => {
clearDraftTag();
}
} catch (error) {
if (requestSeq !== metaRequestSeq) return;
if (requestSeq !== metaRequestSeq || isStaleRun(run, org)) return;
errorMessage.value = buildErrorMessage(error);
}
};
@@ -926,6 +950,7 @@ const runQuery = async () => {
const payload = queryPayload(range);
if (!org || !stream || !payload) return;
const requestSeq = ++queryRequestSeq;
const run = initRunSeq;
queryLoading.value = true;
errorMessage.value = "";
try {
@@ -933,12 +958,12 @@ const runQuery = async () => {
profilesService.series(org, stream, payload),
profilesService.merge(org, stream, { ...payload, max_nodes: 4096 }),
]);
if (requestSeq !== queryRequestSeq) return;
if (requestSeq !== queryRequestSeq || isStaleRun(run, org)) return;
seriesResponse.value = seriesResult.data;
mergeResult.value = mergeResponseValue.data;
resetStackExpansion(mergeResponseValue.data.root);
} catch (error) {
if (requestSeq !== queryRequestSeq) return;
if (requestSeq !== queryRequestSeq || isStaleRun(run, org)) return;
errorMessage.value = buildErrorMessage(error);
} finally {
if (requestSeq === queryRequestSeq) {
@@ -947,12 +972,59 @@ const runQuery = async () => {
}
};
const queryString = (value: LocationQuery[string]) => (typeof value === "string" ? value : "");
const parseRouteSeed = (query: LocationQuery): RouteSeed | null => {
const stream = queryString(query.stream);
if (!stream) return null;
const from = Number(query.from);
const to = Number(query.to);
const type = queryString(query.profile_type);
const view = queryString(query.view);
return {
stream,
range: from > 0 && to > from ? { startTime: from, endTime: to } : null,
service: queryString(query.service_name) || null,
profileType: type ? `${type}\u0000${queryString(query.profile_unit)}` : null,
filters: parseProfileFilters(queryString(query.filters)),
view: PROFILE_VIEWS.find((item) => item === view) ?? null,
};
};
const applyRouteSeed = (seed: RouteSeed) => {
selectedStream.value = seed.stream;
if (seed.range) {
dateState.value = { ...seed.range, relativeTimePeriod: "", valueType: "absolute" };
}
selectedService.value = seed.service;
selectedProfileType.value = seed.profileType;
};
const initPage = async () => {
resetProfilesState();
await loadStreams();
if (!selectedStream.value) return;
await loadMeta();
await runQuery();
const seed = parseRouteSeed(route.query);
const run = ++initRunSeq;
const unseed = !seed && lastRunSeeded;
lastRunSeeded = !!seed;
seeding = !!seed || unseed;
try {
resetProfilesState();
if (seed) applyRouteSeed(seed);
if (unseed) {
selectedStream.value = null;
dateState.value = defaultDateState();
}
await loadStreams();
if (run !== initRunSeq || !selectedStream.value) return;
await loadMeta();
if (run !== initRunSeq) return;
if (seed) {
appliedFilters.value = seed.filters;
if (seed.view) activeView.value = seed.view;
}
await runQuery();
} finally {
if (run === initRunSeq) seeding = false;
}
};
watch(draftTagKey, async (key, previousKey) => {
@@ -973,7 +1045,7 @@ watch([selectedService, selectedProfileType], async () => {
});
watch(selectedStream, async (stream, previousStream) => {
if (!stream || stream === previousStream) return;
if (seeding || !stream || stream === previousStream) return;
clearDraftTag();
selectedService.value = null;
appliedFilters.value = [];
@@ -982,6 +1054,7 @@ watch(selectedStream, async (stream, previousStream) => {
});
watch(dateState, async () => {
if (seeding) return;
await loadMeta();
if (draftTagKey.value) {
await loadTagValues();
@@ -996,4 +1069,12 @@ watch(
},
{ immediate: true },
);
watch(
() => JSON.stringify(parseRouteSeed(route.query)),
async () => {
if (route.name !== pageRouteName) return;
await initPage();
},
);
</script>
+351 -11
View File
@@ -17,6 +17,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { defineComponent, h, KeepAlive, reactive, ref } from "vue";
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
import Index from "@/plugins/traces/Index.vue";
import DateTime from "@/components/DateTime.vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
@@ -214,9 +215,9 @@ vi.mock("@/composables/useTraces", () => ({
formatTracesMetaData: vi.fn((hits) => hits),
setServiceColors: mockSetServiceColors,
loadLocalLogFilterField: vi.fn(),
updatedLocalLogFilterField: vi.fn(),
updatedLocalLogFilterField: mockUpdatedLocalLogFilterField,
loadTracesParser: vi.fn().mockResolvedValue(undefined),
tracesParser: { value: { astify: vi.fn() } },
tracesParser: { value: trackTracesParser({ astify: vi.fn(() => ({ where: null })) }) },
}),
}));
@@ -232,6 +233,25 @@ const { mockGetStreams, mockGetStream, mockSetServiceColors } = vi.hoisted(() =>
mockSetServiceColors: vi.fn(),
}));
const { mockUpdatedLocalLogFilterField, mockToast } = vi.hoisted(() => ({
mockUpdatedLocalLogFilterField: vi.fn(),
mockToast: vi.fn(),
}));
vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({
...((await importOriginal()) as any),
toast: mockToast,
}));
// Each mount gets its own parser, so a spy call's parser argument identifies which test's mount made it.
const { createdTracesParsers, trackTracesParser } = vi.hoisted(() => {
const createdTracesParsers: unknown[] = [];
return {
createdTracesParsers,
trackTracesParser: <T extends object>(p: T): T => (createdTracesParsers.push(p), p),
};
});
// Hoisted so tests can assert resetSearchObj was called by the component lifecycle.
const { mockResetSearchObj } = vi.hoisted(() => ({
mockResetSearchObj: vi.fn(),
@@ -263,12 +283,20 @@ const { mockCancelStreamQueryBasedOnRequestId, mockFetchQueryDataWithHttpStream
}),
);
vi.mock("@/composables/useStreamingSearch", () => ({
default: () => ({
fetchQueryDataWithHttpStream: mockFetchQueryDataWithHttpStream,
cancelStreamQueryBasedOnRequestId: mockCancelStreamQueryBasedOnRequestId,
}),
}));
// The owning instance becomes the call's `this`, so tests can tell their search from a leftover instance's.
vi.mock("@/composables/useStreamingSearch", async () => {
const { getCurrentInstance } = await import("vue");
return {
default: () => {
const owner = getCurrentInstance();
return {
fetchQueryDataWithHttpStream: (...args: unknown[]) =>
mockFetchQueryDataWithHttpStream.apply(owner, args),
cancelStreamQueryBasedOnRequestId: mockCancelStreamQueryBasedOnRequestId,
};
},
};
});
vi.mock("@/composables/useDurationPercentiles", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
@@ -280,7 +308,9 @@ vi.mock("@/composables/useDurationPercentiles", async (importOriginal) => {
// Hoisted so tests can assert on the spy and override its return value per test.
const { mockParseSpanKindWhereClause } = vi.hoisted(() => ({
mockParseSpanKindWhereClause: vi.fn((whereClause: string) => whereClause),
mockParseSpanKindWhereClause: vi.fn(
(whereClause: string, _parser?: unknown, _streamName?: string) => whereClause,
),
}));
// Use importOriginal so SPAN_KIND_MAP and SPAN_KIND_LABEL_TO_KEY remain available,
@@ -363,6 +393,7 @@ describe("Index.vue (Main Traces Page)", () => {
mockSearchObj.data.queryResults = { hits: [] };
mockSearchObj.data.errorMsg = "";
mockSearchObj.data.editorValue = "";
createdTracesParsers.length = 0;
// Reset hoisted mocks to safe defaults for each test
mockResetSearchObj.mockReset();
@@ -1356,6 +1387,26 @@ describe("Index.vue (Main Traces Page)", () => {
delete store.state.zoConfig.auto_query_enabled;
});
it("should render the drill-down target in the search tab", async () => {
wrapper = mountWithSearchBarStub();
await flushPromises();
expect(wrapper.vm.activeTab).toBe("search");
expect(wrapper.find("#traces-drill-down-page").exists()).toBe(true);
});
it("should open the analysis dashboard on SearchResult when SearchBar emits drill-down", async () => {
wrapper = mountWithSearchBarStub();
await flushPromises();
const openUnifiedAnalysisDashboard = vi.fn();
wrapper.vm.searchResultRef = { openUnifiedAnalysisDashboard };
wrapper.findComponent('[data-test="logs-search-bar"]').vm.$emit("drill-down");
await flushPromises();
expect(openUnifiedAnalysisDashboard).toHaveBeenCalledTimes(1);
});
it("should call applyFilters with all filter terms when metrics filters are updated", async () => {
wrapper = mountWithSearchBarStub();
await flushPromises();
@@ -2089,10 +2140,18 @@ describe("Index.vue (Main Traces Page)", () => {
await wrapper.vm.searchData();
await flushPromises();
// Guards the parser filter below from going vacuous if the parser argument stops being passed.
expect(parseSpy.mock.calls.some(([, parser]) => createdTracesParsers.includes(parser))).toBe(
true,
);
// parseDurationWhereClause returns the input unchanged for empty strings (no-op)
// Either it was not called, or if called, it was with an empty string and returned it
const callsWithNonEmpty = parseSpy.mock.calls.filter(
([clause]) => typeof clause === "string" && clause.trim() !== "",
([clause, parser]) =>
createdTracesParsers.includes(parser) &&
typeof clause === "string" &&
clause.trim() !== "",
);
expect(callsWithNonEmpty.length).toBe(0);
});
@@ -2291,10 +2350,20 @@ describe("Index.vue (Main Traces Page)", () => {
await wrapper.vm.searchData();
await flushPromises();
// Guards the parser filter below from going vacuous if the parser argument stops being passed.
expect(
mockParseSpanKindWhereClause.mock.calls.some(([, parser]) =>
createdTracesParsers.includes(parser),
),
).toBe(true);
// parseSpanKindWhereClause is guarded by `whereClause.trim() != ""` in buildSearch;
// for an empty editorValue the spy must not have been called with a non-empty string.
const callsWithNonEmpty = mockParseSpanKindWhereClause.mock.calls.filter(
([clause]) => typeof clause === "string" && clause.trim() !== "",
([clause, parser]) =>
createdTracesParsers.includes(parser) &&
typeof clause === "string" &&
clause.trim() !== "",
);
expect(callsWithNonEmpty.length).toBe(0);
});
@@ -2973,6 +3042,277 @@ describe("Index.vue (Main Traces Page)", () => {
});
});
describe("apply-saved-view", () => {
const originalDatetime = { ...mockSearchObj.data.datetime };
const savedView = (overrides: Record<string, unknown> = {}) => ({
version: 1,
stream: { label: "default", value: "default" },
editorValue: "service_name = 'checkout'",
datetime: { type: "absolute", relativeTimePeriod: "15m", startTime: 111, endTime: 222 },
searchMode: "spans",
sortBy: "duration",
sortOrder: "asc",
selectedFields: ["service_name", "http_method"],
...overrides,
});
// Unmounted instances from earlier tests can still land a mount search on the shared mock.
const ownSearches = () =>
mockFetchQueryDataWithHttpStream.mock.contexts.filter((owner) => owner === wrapper.vm.$);
// Field grouping inside extractFields awaits across macrotasks.
const drain = async () => {
for (let i = 0; i < 3; i++) {
await flushPromises();
await new Promise((r) => setTimeout(r, 0));
}
};
const mountPage = async (searchBar: any = true) => {
wrapper = mount(Index, {
attachTo: node,
global: {
plugins: [i18n, router],
provide: { store: store },
stubs: {
"search-bar": searchBar,
"index-list": true,
"search-result": true,
"service-graph": true,
"services-catalog": true,
SanitizedHtmlRenderer: true,
},
},
});
await flushPromises();
await vi.waitFor(
() => expect(mockSearchObj.data.stream.selectedStream.value).toBe("default"),
{ timeout: 2000 },
);
await vi.waitFor(() => expect(ownSearches().length).toBeGreaterThan(0), {
timeout: 2000,
});
await drain();
mockFetchQueryDataWithHttpStream.mockClear();
mockGetStream.mockClear();
mockUpdatedLocalLogFilterField.mockClear();
routerReplaceSpy.mockClear();
};
const applyView = async (view: Record<string, unknown>) => {
await wrapper.findComponent({ name: "search-bar" }).vm.$emit("apply-saved-view", view);
await drain();
};
const applyViewAndWaitForSearch = async (view: Record<string, unknown>) => {
await applyView(view);
await vi.waitFor(() => expect(ownSearches().length).toBeGreaterThan(0), {
timeout: 2000,
});
await drain();
};
afterEach(() => {
mockSearchObj.meta.resultGrid.sortBy = "start_time";
mockSearchObj.meta.resultGrid.sortOrder = "desc";
mockSearchObj.data.datetime = { ...originalDatetime };
mockSearchObj.data.stream.selectedFields = [];
mockSearchObj.data.resultGrid.columns = [];
mockFetchQueryDataWithHttpStream.mockReset();
});
it("restores every field before running exactly one search", async () => {
await mountPage();
const stateAtSearch: any[] = [];
mockFetchQueryDataWithHttpStream.mockImplementation(function (this: unknown) {
if (this !== wrapper.vm.$) return;
stateAtSearch.push({
editorValue: mockSearchObj.data.editorValue,
searchMode: mockSearchObj.meta.searchMode,
datetime: { ...mockSearchObj.data.datetime },
sortBy: mockSearchObj.meta.resultGrid.sortBy,
sortOrder: mockSearchObj.meta.resultGrid.sortOrder,
selectedFields: [...mockSearchObj.data.stream.selectedFields],
});
});
await applyViewAndWaitForSearch(savedView());
expect(stateAtSearch).toEqual([
{
editorValue: "service_name = 'checkout'",
searchMode: "spans",
datetime: expect.objectContaining({ type: "absolute", startTime: 111, endTime: 222 }),
sortBy: "duration",
sortOrder: "asc",
selectedFields: ["service_name", "http_method"],
},
]);
expect(mockGetStream).not.toHaveBeenCalled();
expect(mockUpdatedLocalLogFilterField).toHaveBeenCalledWith("spans");
});
describe("restores the picker's date type", () => {
const ABSOLUTE = {
type: "absolute",
relativeTimePeriod: "15m",
startTime: 1752490000000000,
endTime: 1752490900000000,
};
const RELATIVE = { type: "relative", relativeTimePeriod: "2h", startTime: 0, endTime: 0 };
let pickerEmits: any[];
// Pinned default-type, so only Index.vue itself can move the picker's type.
const searchBarWithPicker = (defaultType: string) =>
defineComponent({
name: "search-bar",
emits: ["apply-saved-view"],
setup() {
return { dateTimeRef: ref<any>(null) };
},
render() {
return h(DateTime, {
ref: "dateTimeRef",
autoApply: true,
defaultType,
defaultRelativeTime: "15m",
"onOn:date-change": (value: any) => pickerEmits.push(value),
});
},
});
const picker = () => wrapper.findComponent(DateTime).vm as any;
beforeEach(() => {
pickerEmits = [];
});
it("switches a relative picker to absolute with one search", async () => {
await mountPage(searchBarWithPicker("relative"));
pickerEmits.length = 0;
await applyViewAndWaitForSearch(savedView({ datetime: ABSOLUTE }));
expect(picker().selectedType).toBe("absolute");
expect(ownSearches()).toHaveLength(1);
expect(pickerEmits.filter((v) => v.userChangedValue)).toEqual([]);
});
it("switches an absolute picker to relative with one search", async () => {
await mountPage(searchBarWithPicker("absolute"));
pickerEmits.length = 0;
await applyViewAndWaitForSearch(savedView({ datetime: RELATIVE }));
expect(picker().selectedType).toBe("relative");
expect(picker().relativeValue).toBe(2);
expect(picker().relativePeriod).toBe("h");
expect(ownSearches()).toHaveLength(1);
expect(pickerEmits.filter((v) => v.userChangedValue)).toEqual([]);
});
});
it("extracts fields for a different stream without clearing the filter", async () => {
await mountPage();
await applyViewAndWaitForSearch(
savedView({ stream: { label: "test-stream", value: "test-stream" } }),
);
expect(mockGetStream).toHaveBeenCalledWith("test-stream", "traces", true);
expect(mockSearchObj.data.stream.selectedStream.value).toBe("test-stream");
expect(mockSearchObj.data.editorValue).toBe("service_name = 'checkout'");
expect(ownSearches()).toHaveLength(1);
});
it("applies only the latest view when an earlier apply finishes last", async () => {
await mountPage();
let resolveStreamA!: (value: unknown) => void;
mockGetStream.mockImplementationOnce(
() => new Promise((resolve) => (resolveStreamA = resolve)),
);
const streamA = { label: "test-stream", value: "test-stream" };
const searchBar = wrapper.findComponent({ name: "search-bar" });
searchBar.vm.$emit("apply-saved-view", savedView({ stream: streamA, editorValue: "a" }));
await flushPromises();
await applyViewAndWaitForSearch(savedView({ stream: streamA, editorValue: "b" }));
resolveStreamA(mockStreamList.list.find((s: any) => s.name === "test-stream"));
await drain();
expect(ownSearches()).toHaveLength(1);
expect(mockSearchObj.data.editorValue).toBe("b");
});
it("rebuilds the columns even when the search returns no hits", async () => {
await mountPage();
await applyViewAndWaitForSearch(savedView());
const ids = mockSearchObj.data.resultGrid.columns.map((c: any) => c.id);
expect(ids).toContain("http_method");
});
it("clears trace_id and span_id from a trace-detail URL", async () => {
await mountPage();
routerCurrentRouteSpy.mockReturnValue({
value: {
query: { stream: "default", tab: "traces", trace_id: "abc", span_id: "def" },
name: "traces",
path: "/traces",
},
} as any);
await applyViewAndWaitForSearch(savedView());
expect(routerReplaceSpy).toHaveBeenCalledTimes(1);
const query = (routerReplaceSpy.mock.calls[0][0] as any).query;
expect(query).not.toHaveProperty("trace_id");
expect(query).not.toHaveProperty("span_id");
expect(query.tab).toBe("spans");
});
it("applies a view saved without selectedFields as no selected fields", async () => {
await mountPage();
mockSearchObj.data.stream.selectedFields = ["stale_field"];
const { selectedFields: _omitted, ...view } = savedView();
await applyViewAndWaitForSearch(view);
expect(mockSearchObj.data.stream.selectedFields).toEqual([]);
expect(mockSearchObj.data.editorValue).toBe("service_name = 'checkout'");
expect(ownSearches()).toHaveLength(1);
});
it("toasts an error and leaves state unchanged for an unknown view version", async () => {
await mountPage();
const before = mockSearchObj.data.editorValue;
const modeBefore = mockSearchObj.meta.searchMode;
await applyView(savedView({ version: 2 }));
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
expect(mockSearchObj.data.editorValue).toBe(before);
expect(mockSearchObj.meta.searchMode).toBe(modeBefore);
expect(ownSearches()).toHaveLength(0);
});
it("toasts and leaves state unchanged when the stream no longer exists", async () => {
await mountPage();
const before = mockSearchObj.data.editorValue;
const modeBefore = mockSearchObj.meta.searchMode;
await applyView(savedView({ stream: { label: "gone", value: "gone" } }));
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "warning" }));
expect(mockSearchObj.data.stream.selectedStream.value).toBe("default");
expect(mockSearchObj.data.editorValue).toBe(before);
expect(mockSearchObj.meta.searchMode).toBe(modeBefore);
expect(ownSearches()).toHaveLength(0);
});
});
// Placed last in the file: this test drives fetchQueryDataWithHttpStream's
// handlers directly and calls mockFetchQueryDataWithHttpStream.mockReset()
// at the end, so it cannot leak an implementation into tests declared after it.
+85 -2
View File
@@ -67,8 +67,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@filters-reset="onFiltersReset"
@cancel-query="cancelSearch"
@update:searchMode="onSearchModeChange"
@apply-saved-view="onApplySavedView"
@service-graph-refresh="serviceGraphRef?.refresh()"
@services-catalog-refresh="servicesCatalogRef?.loadServicesCatalog()"
@drill-down="searchResultRef?.openUnifiedAnalysisDashboard()"
/>
</div>
</template>
@@ -103,8 +105,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-if="activeTab === 'search'"
id="tracesThirdLevel"
class="traces-search-result-container relative-position h-full"
class="traces-search-result-container relative-position relative h-full"
>
<!-- Drill down teleports into this; it covers the results so their scroll and brush state survive. -->
<div id="traces-drill-down-page" class="absolute inset-0 z-20 hidden has-[>*]:flex" />
<!-- Note: Splitter max-height to be dynamically calculated with JS -->
<OSplitter
v-model="searchObj.config.splitterModel"
@@ -380,6 +384,17 @@ import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
interface TracesSavedView {
version?: number;
stream: { label: string; value: string };
editorValue: string;
datetime: { type: string; relativeTimePeriod: string; startTime: number; endTime: number };
searchMode: "spans" | "traces";
sortBy: string;
sortOrder: string;
selectedFields?: string[];
}
const SearchBar = defineAsyncComponent(() => import("./SearchBar.vue"));
const IndexList = defineAsyncComponent(() => import("./IndexList.vue"));
const SearchResult = defineAsyncComponent(() => import("./SearchResult.vue"));
@@ -430,7 +445,7 @@ const correlationFilters = useCorrelationFilters({
correlationFilters.watchQuery();
let refreshIntervalID = 0;
const searchResultRef = ref(null);
const searchResultRef = ref<any>(null);
const searchBarRef = ref(null);
const serviceGraphRef = ref<any>(null);
const servicesCatalogRef = ref<any>(null);
@@ -1901,6 +1916,74 @@ const onChangeStream = async () => {
runQueryFn();
};
const syncSavedViewDateTime = async (datetime: TracesSavedView["datetime"]) => {
const picker = searchBarRef.value?.dateTimeRef;
if (!picker) return;
// The picker's date watcher emits after its programmatic marker resets, so the flag mutes it instead.
store.dispatch("setSavedViewFlag", true);
try {
if (datetime.type === "relative") picker.setRelativeTime(datetime.relativeTimePeriod);
else picker.setAbsoluteTime(datetime.startTime, datetime.endTime);
picker.setDateType(datetime.type);
await nextTick();
} finally {
store.dispatch("setSavedViewFlag", false);
}
};
let savedViewApplySeq = 0;
const onApplySavedView = async (view: TracesSavedView) => {
const seq = ++savedViewApplySeq;
if (view.version !== undefined && view.version !== 1) {
toast({ variant: "error", message: t("search.errorWhileApplyingSavedView") });
return;
}
const streamName = view.stream?.value;
const streamChanged = streamName !== searchObj.data.stream.selectedStream.value;
if (
streamChanged &&
!searchObj.data.stream.streamLists.some((s: any) => s.value === streamName)
) {
toast({
variant: "warning",
message: t("traces.savedViewStreamMissing", { stream: streamName }),
});
return;
}
if (streamChanged) {
searchObj.data.stream.selectedStream = { label: streamName, value: streamName };
// Not onChangeStream: it runs a search of its own before the view is restored.
await extractFields();
if (seq !== savedViewApplySeq) return;
}
searchObj.meta.searchMode = view.searchMode;
searchObj.data.datetime = { ...searchObj.data.datetime, ...view.datetime };
await syncSavedViewDateTime(view.datetime);
if (seq !== savedViewApplySeq) return;
searchObj.data.editorValue = view.editorValue;
searchBarRef.value?.setEditorValue?.(view.editorValue);
searchObj.meta.resultGrid.sortBy = view.sortBy;
searchObj.meta.resultGrid.sortOrder = view.sortOrder;
// A zero-hit search never rebuilds the columns, so the view's columns are applied here.
searchObj.data.stream.selectedFields = [...(view.selectedFields ?? [])];
rebuildColumns();
updatedLocalLogFilterField(view.searchMode);
// getUrlQueryParams copies trace_id/span_id from the route, which would reopen a trace.
const query = { ...router.currentRoute.value.query, tab: view.searchMode };
delete query.trace_id;
delete query.span_id;
await router.replace({ query });
if (seq !== savedViewApplySeq) return;
if (view.editorValue && searchObj.data.stream.selectedStreamFields.length) {
restoreFilters(view.editorValue);
}
runQueryFn();
};
const streamChangeDialog = ref({ show: false, pendingStream: "" });
const applyStreamChange = async (newStream: string) => {
+350 -48
View File
@@ -98,6 +98,39 @@ vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({
toast: toastMock,
}));
const {
mockSavedViewsGet,
mockSavedViewsPost,
mockSavedViewsPut,
mockSavedViewsDelete,
mockGetViewDetail,
mockConfirm,
} = vi.hoisted(() => ({
mockSavedViewsGet: vi.fn(),
mockSavedViewsPost: vi.fn(),
mockSavedViewsPut: vi.fn(),
mockSavedViewsDelete: vi.fn(),
mockGetViewDetail: vi.fn(),
mockConfirm: vi.fn(),
}));
vi.mock("@/services/saved_views", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: {
get: mockSavedViewsGet,
post: mockSavedViewsPost,
put: mockSavedViewsPut,
delete: mockSavedViewsDelete,
getViewDetail: mockGetViewDetail,
},
});
});
vi.mock("@/composables/useConfirmDialog", () => ({
useConfirmDialog: () => ({ confirm: mockConfirm }),
}));
// ---------------------------------------------------------------------------
// Shared mutable searchObj — reset to a fresh copy in every beforeEach so
// that mutations in one test cannot affect the next.
@@ -109,6 +142,7 @@ const makeSearchObj = () =>
runQuery: false,
loading: false,
loadingStream: false,
searchApplied: false,
config: {
refreshTimes: [
[
@@ -342,9 +376,11 @@ const sharedStubs = {
ODropdownItem: {
name: "ODropdownItem",
template:
'<div class="o-dropdown-item-stub" v-bind="$attrs" @click="$emit(\'select\')"><slot name="icon-left" /><slot /></div>',
'<div class="o-dropdown-item-stub" v-bind="$attrs" @click="$emit(\'select\')"><slot name="icon-left" /><slot /><slot name="icon-right" /></div>',
emits: ["select"],
},
ODropdownGroup: { template: "<div><slot /></div>" },
ODropdownSeparator: { template: "<hr />" },
};
// ---------------------------------------------------------------------------
@@ -637,33 +673,173 @@ describe("SearchBar", () => {
});
// -------------------------------------------------------------------------
describe("histogram toggle", () => {
it("should render the histogram (metrics) toggle", async () => {
describe("RED Metrics toggle", () => {
it("should render the metrics toggle inside the More menu, not on the toolbar", async () => {
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="traces-search-bar-show-metrics-toggle-btn"]').exists()).toBe(
true,
);
const menuItem = wrapper.find('[data-test="traces-search-bar-menu-metrics-btn"]');
expect(menuItem.exists()).toBe(true);
expect(menuItem.text()).toContain("traces.redMetrics");
expect(
menuItem.find('[data-test="traces-search-bar-show-metrics-toggle-btn"]').exists(),
).toBe(true);
expect(
wrapper.findAll('[data-test="traces-search-bar-show-metrics-toggle-btn"]'),
).toHaveLength(1);
});
it("should reflect showHistogram=false from searchObj", async () => {
searchObjInstance.meta.showHistogram = false;
wrapper = mountSearchBar();
await flushPromises();
// The toggle is bound via v-model to searchObj.meta.showHistogram.
// Verify the reactive source has the correct initial value.
expect(searchObjInstance.meta.showHistogram).toBe(false);
});
it("should reflect showHistogram=true from searchObj", async () => {
it("should toggle showHistogram when the menu item is selected", async () => {
searchObjInstance.meta.showHistogram = true;
wrapper = mountSearchBar();
await flushPromises();
const item = wrapper
.findAllComponents({ name: "ODropdownItem" })
.find((c) => c.attributes("data-test") === "traces-search-bar-menu-metrics-btn")!;
item.vm.$emit("select", new Event("select"));
await flushPromises();
expect(searchObjInstance.meta.showHistogram).toBe(false);
item.vm.$emit("select", new Event("select"));
await flushPromises();
expect(searchObjInstance.meta.showHistogram).toBe(true);
});
it("should flip showHistogram exactly once when the switch itself is clicked", async () => {
searchObjInstance.meta.showHistogram = true;
wrapper = mountSearchBar();
await flushPromises();
await wrapper
.find(
'[data-test="traces-search-bar-menu-metrics-btn"] [data-test="traces-search-bar-show-metrics-toggle-btn"]',
)
.trigger("click");
await flushPromises();
expect(searchObjInstance.meta.showHistogram).toBe(false);
});
it("should keep the Syntax Guide in the More menu", async () => {
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="traces-search-bar-syntax-guide-btn"]').exists()).toBe(true);
});
it.each(["service-graph", "services-catalog"] as const)(
"should hide the metrics toggle in %s mode",
async (mode) => {
searchObjInstance.meta.searchMode = mode;
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="traces-search-bar-menu-metrics-btn"]').exists()).toBe(
false,
);
},
);
});
// -------------------------------------------------------------------------
describe("Drill down button", () => {
const applySearch = () => {
searchObjInstance.searchApplied = true;
searchObjInstance.data.errorMsg = "";
searchObjInstance.data.stream.streamLists = [{ label: "default", value: "default" }] as any;
};
it.each(["spans", "traces"] as const)(
"should render right after the tab group in %s mode once a search is applied",
async (mode) => {
searchObjInstance.meta.searchMode = mode;
applySearch();
wrapper = mountSearchBar();
await flushPromises();
const btn = wrapper.find('[data-test="insights-button"]');
expect(btn.exists()).toBe(true);
expect(btn.text().includes("traces.drillDown")).toBe(
!(wrapper.vm as any).shouldHideToggleText,
);
const toggleGroup = wrapper.find(".o-toggle-group-stub").element;
expect(toggleGroup.nextElementSibling).toBe(btn.element);
},
);
it.each(["service-graph", "services-catalog"] as const)(
"should be hidden in %s mode",
async (mode) => {
searchObjInstance.meta.searchMode = mode;
applySearch();
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="insights-button"]').exists()).toBe(false);
},
);
it("should be hidden before a search is applied", async () => {
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="insights-button"]').exists()).toBe(false);
});
it("should be hidden when the search errored", async () => {
applySearch();
searchObjInstance.data.errorMsg = "boom";
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="insights-button"]').exists()).toBe(false);
});
it("should be hidden while streams are loading", async () => {
applySearch();
searchObjInstance.loadingStream = true;
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="insights-button"]').exists()).toBe(false);
});
it("should be hidden while a search is loading", async () => {
applySearch();
searchObjInstance.loading = true;
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="insights-button"]').exists()).toBe(false);
});
it("should be hidden when the org has no trace streams", async () => {
applySearch();
searchObjInstance.data.stream.streamLists = [];
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="insights-button"]').exists()).toBe(false);
});
it("should be hidden when no stream is selected", async () => {
applySearch();
searchObjInstance.data.stream.selectedStream = { label: "", value: "" };
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="insights-button"]').exists()).toBe(false);
});
it("should emit drill-down when clicked", async () => {
applySearch();
wrapper = mountSearchBar();
await flushPromises();
await wrapper.find('[data-test="insights-button"]').trigger("click");
expect(wrapper.emitted("drill-down")).toHaveLength(1);
});
});
// -------------------------------------------------------------------------
@@ -1237,40 +1413,17 @@ describe("SearchBar", () => {
// -------------------------------------------------------------------------
describe("tooltips on icon-only buttons", () => {
it("should have a tooltip inside the reset filters button", async () => {
it("should have the analysis tooltip inside the Drill down button", async () => {
searchObjInstance.searchApplied = true;
searchObjInstance.data.stream.streamLists = [{ label: "default", value: "default" }] as any;
wrapper = mountSearchBar();
await flushPromises();
const resetBtn = wrapper.find('[data-test="traces-search-bar-reset-filters-btn"]');
expect(resetBtn.exists()).toBe(true);
expect(wrapper.findComponent({ name: "OTooltip" }).exists()).toBe(true);
});
it("should have a tooltip inside the Traces mode toggle button", async () => {
wrapper = mountSearchBar();
await flushPromises();
const searchBtn = wrapper.find('[data-test="traces-search-mode-traces-btn"]');
expect(searchBtn.exists()).toBe(true);
expect(wrapper.findComponent({ name: "OTooltip" }).exists()).toBe(true);
});
it("should have a tooltip inside the Traces search mode button", async () => {
wrapper = mountSearchBar();
await flushPromises();
const tracesBtn = wrapper.find('[data-test="traces-search-mode-traces-btn"]');
expect(tracesBtn.exists()).toBe(true);
expect(wrapper.findComponent({ name: "OTooltip" }).exists()).toBe(true);
});
it("should have a tooltip inside the Spans search mode button", async () => {
wrapper = mountSearchBar();
await flushPromises();
const spansBtn = wrapper.find('[data-test="traces-search-mode-spans-btn"]');
expect(spansBtn.exists()).toBe(true);
expect(wrapper.findComponent({ name: "OTooltip" }).exists()).toBe(true);
const tooltip = wrapper
.find('[data-test="insights-button"]')
.findComponent({ name: "OTooltip" });
expect(tooltip.exists()).toBe(true);
expect(tooltip.props("content")).toBe("volumeInsights.analyzeTooltipTraces");
});
});
@@ -1391,4 +1544,153 @@ describe("SearchBar", () => {
expect(wrapper.emitted("searchdata")).toBeUndefined();
});
});
describe("saved views", () => {
const tracesView = { view_id: "t1", view_name: "checkout errors", view_type: "traces" };
const logsView = { view_id: "l1", view_name: "logs view" };
const expectedData = () => ({
version: 1,
stream: { label: "default", value: "default" },
editorValue: "span_status = 'ERROR'",
datetime: { type: "relative", relativeTimePeriod: "1h", startTime: 10, endTime: 20 },
searchMode: "spans",
sortBy: "duration",
sortOrder: "asc",
selectedFields: ["service_name", "duration"],
});
beforeEach(() => {
mockSavedViewsGet.mockResolvedValue({ data: { views: [tracesView, logsView] } });
mockSavedViewsPost.mockResolvedValue({ status: 200, data: { view_id: "n1" } });
mockSavedViewsPut.mockResolvedValue({ status: 200, data: {} });
mockSavedViewsDelete.mockResolvedValue({ status: 200, data: {} });
mockConfirm.mockResolvedValue(true);
searchObjInstance.meta.searchMode = "spans";
searchObjInstance.data.editorValue = "span_status = 'ERROR'";
searchObjInstance.data.datetime = {
startTime: 10,
endTime: 20,
relativeTimePeriod: "1h",
type: "relative",
queryRangeRestrictionInHour: 0,
queryRangeRestrictionMsg: "",
};
searchObjInstance.meta.resultGrid.sortBy = "duration";
searchObjInstance.meta.resultGrid.sortOrder = "asc";
searchObjInstance.data.stream.selectedFields = ["service_name", "duration"];
});
it("lists only traces views", async () => {
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="traces-search-bar-saved-views-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="traces-saved-view-apply-t1"]').exists()).toBe(true);
expect(wrapper.find('[data-test="traces-saved-view-apply-l1"]').exists()).toBe(false);
});
it("marks the saved-views button as a menu with a drop-down caret, as logs does", async () => {
wrapper = mountSearchBar();
await flushPromises();
const icons = wrapper
.find('[data-test="traces-search-bar-saved-views-btn"]')
.findAllComponents({ name: "OIcon" })
.map((c) => c.props("name"));
expect(icons).toEqual(["saved-search", "arrow-drop-down"]);
});
it.each(["service-graph", "services-catalog"])("is hidden on the %s tab", async (mode) => {
searchObjInstance.meta.searchMode = mode as any;
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="traces-search-bar-saved-views-btn"]').exists()).toBe(false);
});
it("save serialises exactly the view fields", async () => {
wrapper = mountSearchBar();
await flushPromises();
(wrapper.vm as any).newViewName = "checkout errors";
await (wrapper.vm as any).saveAsNewView();
expect(mockSavedViewsPost).toHaveBeenCalledTimes(1);
expect(mockSavedViewsPost.mock.calls[0][1]).toEqual({
view_name: "checkout errors",
view_type: "traces",
data: expectedData(),
});
});
it("apply emits apply-saved-view with the stored data", async () => {
mockGetViewDetail.mockResolvedValue({ data: { ...tracesView, data: expectedData() } });
wrapper = mountSearchBar();
await flushPromises();
await wrapper.find('[data-test="traces-saved-view-apply-t1"]').trigger("click");
await flushPromises();
expect(mockGetViewDetail).toHaveBeenCalledWith(expect.any(String), "t1");
expect(wrapper.emitted("apply-saved-view")?.[0]).toEqual([expectedData()]);
});
it("emits only the latest view when an earlier detail resolves last", async () => {
let resolveA!: (value: unknown) => void;
mockGetViewDetail
.mockReturnValueOnce(new Promise((resolve) => (resolveA = resolve)))
.mockResolvedValueOnce({ data: { data: { ...expectedData(), editorValue: "view-b" } } });
wrapper = mountSearchBar();
await flushPromises();
const applyA = (wrapper.vm as any).applySavedView({ view_id: "a" });
const applyB = (wrapper.vm as any).applySavedView({ view_id: "b" });
await applyB;
resolveA({ data: { data: { ...expectedData(), editorValue: "view-a" } } });
await applyA;
await flushPromises();
const emitted = wrapper.emitted("apply-saved-view") ?? [];
expect(emitted).toHaveLength(1);
expect((emitted[0][0] as any).editorValue).toBe("view-b");
});
it("update overwrites the view with the current state", async () => {
wrapper = mountSearchBar();
await flushPromises();
await wrapper.find('[data-test="traces-saved-view-update-t1"]').trigger("click");
await flushPromises();
expect(mockSavedViewsPut).toHaveBeenCalledWith(expect.any(String), "t1", {
view_name: "checkout errors",
data: expectedData(),
});
expect(wrapper.emitted("apply-saved-view")).toBeUndefined();
});
it("delete calls the delete mutation once confirmed", async () => {
wrapper = mountSearchBar();
await flushPromises();
await wrapper.find('[data-test="traces-saved-view-delete-t1"]').trigger("click");
await flushPromises();
expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1");
});
it("delete does nothing when not confirmed", async () => {
mockConfirm.mockResolvedValue(false);
wrapper = mountSearchBar();
await flushPromises();
await wrapper.find('[data-test="traces-saved-view-delete-t1"]').trigger("click");
await flushPromises();
expect(mockSavedViewsDelete).not.toHaveBeenCalled();
});
});
});
+270 -18
View File
@@ -67,6 +67,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OToggleGroupItem>
</OToggleGroup>
<OButton
v-if="showDrillDown"
data-test="insights-button"
variant="outline"
size="xs"
@click="$emit('drill-down')"
>
<template #icon-left>
<OIcon name="timeline" size="sm" class="shrink-0" />
</template>
<span v-if="!shouldHideToggleText">{{ t("traces.drillDown") }}</span>
<OTooltip :content="t('volumeInsights.analyzeTooltipTraces')" />
</OButton>
<!-- Show search controls only when not on Service Graph or Services Catalog -->
<template
v-if="
@@ -86,24 +100,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<span v-if="!shouldHideResetText">{{ t("common.reset") }}</span>
</OButton>
<div
class="border-button-outline-border rounded-default hover:bg-button-outline-hover-bg flex cursor-pointer items-center justify-center border px-1.5 py-1 transition-all duration-200"
>
<OSwitch
data-test="traces-search-bar-show-metrics-toggle-btn"
v-model="searchObj.meta.showHistogram"
class="o2-toggle-button-xs flex items-center justify-center pe-1"
size="lg"
/>
<OIcon name="bar-chart" size="sm" class="shrink-0" />
<OTooltip :content="raw('RED Metrics')" />
</div>
</template>
<!-- More menu: Syntax Guide — always last.
Sessions + LLM Insights were removed from Traces; they now
live as standalone pages under AI Observability. -->
<ODropdown side="bottom" align="start">
<template #trigger>
<OButton
@@ -117,6 +115,33 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</template>
<ODropdownItem
v-if="
searchObj.meta.searchMode !== 'service-graph' &&
searchObj.meta.searchMode !== 'services-catalog'
"
data-test="traces-search-bar-menu-metrics-btn"
@select.prevent="searchObj.meta.showHistogram = !searchObj.meta.showHistogram"
>
<template #icon-left>
<span
class="rounded-default bg-section-header-bg text-text-secondary inline-flex h-7 w-7 shrink-0 items-center justify-center"
>
<OIcon name="bar-chart" size="sm" />
</span>
</template>
{{ t("traces.redMetrics") }}
<template #icon-right>
<OSwitch
v-model="searchObj.meta.showHistogram"
size="md"
class="ms-auto"
data-test="traces-search-bar-show-metrics-toggle-btn"
@click.stop
/>
</template>
</ODropdownItem>
<SyntaxGuide
:sqlmode="searchObj.meta.sqlMode"
:menuItem="true"
@@ -244,6 +269,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OIcon name="download" size="sm" />
</OButton>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
data-test="traces-search-bar-saved-views-btn"
variant="outline"
size="icon-toolbar"
:title="t('search.listSavedViews')"
>
<OIcon name="saved-search" size="sm" />
<OIcon name="arrow-drop-down" size="sm" class="-ms-0.5" />
</OButton>
</template>
<ODropdownGroup :label="t('search.savedViewsLabel')">
<div
v-if="tracesSavedViews.length"
class="max-h-72 overflow-y-auto overscroll-contain"
>
<ODropdownItem
v-for="view in tracesSavedViews"
:key="view.view_id"
:data-test="`traces-saved-view-apply-${view.view_id}`"
@select="applySavedView(view)"
>
<span class="max-w-56 truncate">{{ view.view_name }}</span>
<template #icon-right>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="edit"
class="ms-auto"
:title="t('search.updateSavedViewWithCurrent')"
:data-test="`traces-saved-view-update-${view.view_id}`"
@click.stop.prevent="updateSavedViewFromCurrent(view)"
/>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="delete"
:title="t('search.deleteSavedView')"
:data-test="`traces-saved-view-delete-${view.view_id}`"
@click.stop.prevent="deleteTracesSavedView(view)"
/>
</template>
</ODropdownItem>
</div>
<ODropdownItem v-else disabled>
{{ t("search.savedViewsNotFound") }}
</ODropdownItem>
</ODropdownGroup>
<ODropdownSeparator />
<ODropdownItem
icon-left="save"
data-test="traces-saved-view-create"
@select="openSaveViewDialog"
>
{{ t("search.createSavedView") }}
</ODropdownItem>
</ODropdown>
<ShareButton
data-test="logs-search-bar-share-link-btn"
:url="tracesShareURL"
@@ -357,6 +440,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
</div>
<ODialog
v-model:open="saveViewDialogOpen"
size="xs"
:title="t('search.createSavedView')"
:primary-button-label="t('common.save')"
:secondary-button-label="t('common.cancel')"
:primary-button-loading="isSavingView"
@click:primary="saveAsNewView"
@click:secondary="saveViewDialogOpen = false"
>
<OInput
v-model="newViewName"
:label="t('search.savedViewName')"
autofocus
data-test="traces-saved-view-name-input"
/>
</ODialog>
</div>
</template>
@@ -373,7 +473,7 @@ import {
toRef,
} from "vue";
import { useQueryPlaceholder } from "@/components/logs/useQueryPlaceholder";
import { raw, useI18nTyped } from "@/types/i18n";
import { useI18nTyped } from "@/types/i18n";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
@@ -385,6 +485,10 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
@@ -408,6 +512,16 @@ import { isDatetimeChanged } from "./tracesSearchBar.utils";
import { toast } from "@/lib/feedback/Toast/useToast";
import { downloadFile } from "@/utils/dom";
import { toCsv } from "@/utils/csv";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { useOrgId } from "@/composables/query/useOrgId";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import savedViewsService from "@/services/saved_views";
import {
savedViewsQuery,
createSavedViewMutation,
updateSavedViewMutation,
deleteSavedViewMutation,
} from "@/services/saved_views.queries";
export default defineComponent({
name: "ComponentSearchSearchBar",
@@ -421,6 +535,10 @@ export default defineComponent({
OIcon,
ODropdown,
ODropdownItem,
ODropdownGroup,
ODropdownSeparator,
ODialog,
OInput,
OSwitch,
OSelect,
OTooltip,
@@ -436,6 +554,8 @@ export default defineComponent({
"onChangeTimezone",
"service-graph-refresh",
"services-catalog-refresh",
"apply-saved-view",
"drill-down",
],
props: {
fieldValues: {
@@ -858,6 +978,116 @@ export default defineComponent({
{ excludeMatchAll: true },
);
const orgId = useOrgId();
const savedViewsList = useQuery(() => ({
...savedViewsQuery(orgId.value),
enabled: !!orgId.value,
}));
const tracesSavedViews = computed(() =>
(savedViewsList.data.value ?? []).filter((view) => view.view_type === "traces"),
);
const createSavedView = useMutation(() => createSavedViewMutation(orgId.value));
const updateSavedView = useMutation(() => updateSavedViewMutation(orgId.value));
const deleteSavedView = useMutation(() => deleteSavedViewMutation(orgId.value));
const isSavingView = createSavedView.isPending;
const saveViewDialogOpen = ref(false);
const newViewName = ref("");
const { confirm } = useConfirmDialog();
const buildSavedViewData = () => {
const { datetime, stream } = searchObj.data;
return {
version: 1,
stream: { label: stream.selectedStream.label, value: stream.selectedStream.value },
editorValue: searchObj.data.editorValue,
datetime: {
type: datetime.type,
relativeTimePeriod: datetime.relativeTimePeriod,
startTime: datetime.startTime,
endTime: datetime.endTime,
},
searchMode: searchObj.meta.searchMode,
sortBy: searchObj.meta.resultGrid.sortBy,
sortOrder: searchObj.meta.resultGrid.sortOrder,
selectedFields: [...stream.selectedFields],
};
};
const errorDetail = (err) => err?.response?.data?.message ?? err?.message ?? "";
const openSaveViewDialog = () => {
newViewName.value = "";
saveViewDialogOpen.value = true;
};
const saveAsNewView = async () => {
const viewName = newViewName.value.trim();
if (!viewName) {
toast({ message: t("logs.searchBar.provideValidViewName"), variant: "warning" });
return;
}
try {
await createSavedView.mutateAsync({
view_name: viewName,
view_type: "traces",
data: buildSavedViewData(),
});
saveViewDialogOpen.value = false;
toast({ message: t("search.viewCreatedSuccessfully"), variant: "success" });
} catch (err) {
toast({
message: t("search.errorCreatingSavedViewDetail", { detail: errorDetail(err) }),
variant: "error",
});
}
};
const updateSavedViewFromCurrent = async (view) => {
try {
await updateSavedView.mutateAsync({
viewId: view.view_id,
view: { view_name: view.view_name, data: buildSavedViewData() },
});
toast({ message: t("search.viewUpdatedSuccessfully"), variant: "success" });
} catch (err) {
toast({
message: t("search.errorUpdatingSavedViewDetail", { detail: errorDetail(err) }),
variant: "error",
});
}
};
const deleteTracesSavedView = async (view) => {
const confirmed = await confirm({
title: t("search.deleteSavedView"),
message: t("search.deleteSavedViewConfirm"),
});
if (!confirmed) return;
try {
await deleteSavedView.mutateAsync(view.view_id);
toast({ message: t("search.viewDeletedSuccessfully"), variant: "success" });
} catch (err) {
toast({
message: t("search.errorDeletingSavedViewDetail", { detail: errorDetail(err) }),
variant: "error",
});
}
};
let applySavedViewSeq = 0;
const applySavedView = async (view) => {
const seq = ++applySavedViewSeq;
try {
// Uncached on purpose, as in logs: applying must use the server's current copy.
const res = await savedViewsService.getViewDetail(orgId.value, view.view_id);
if (seq !== applySavedViewSeq) return;
emit("apply-saved-view", res.data.data);
} catch (err) {
if (seq !== applySavedViewSeq) return;
toast({ message: t("search.errorWhileApplyingSavedView"), variant: "error" });
}
};
// Responsive toolbar — shared composable tracks available left-section width
const { toolbarLeftRef, toolbarRightRef, availableLeftWidth } = useToolbarResponsive();
@@ -867,9 +1097,21 @@ export default defineComponent({
const shouldHideToggleText = computed(() => availableLeftWidth.value < 750);
const shouldHideResetText = computed(() => availableLeftWidth.value < 540);
// Only while Index renders SearchResult with its header, since the click is routed to it.
const showDrillDown = computed(
() =>
searchObj.meta.searchMode !== "service-graph" &&
searchObj.meta.searchMode !== "services-catalog" &&
!searchObj.loadingStream &&
!searchObj.loading &&
searchObj.data.stream.streamLists.length > 0 &&
!!searchObj.data.stream.selectedStream?.value &&
!searchObj.data.errorMsg?.trim()?.length &&
!!searchObj.searchApplied,
);
return {
t,
raw,
router,
store,
searchObj,
@@ -906,6 +1148,16 @@ export default defineComponent({
toolbarRightRef,
shouldHideToggleText,
shouldHideResetText,
showDrillDown,
tracesSavedViews,
isSavingView,
saveViewDialogOpen,
newViewName,
openSaveViewDialog,
saveAsNewView,
updateSavedViewFromCurrent,
deleteTracesSavedView,
applySavedView,
};
},
computed: {
-36
View File
@@ -19,14 +19,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div data-test="traces-search-result" class="h-full overflow-hidden">
<div class="bg-card-glass-bg flex h-full flex-col overflow-hidden">
<!-- Section header: title + count badge + insights + pagination -->
<div
v-if="
searchObj.data.stream.selectedStream.value &&
!searchObj.data.errorMsg?.trim()?.length &&
searchObj.searchApplied
"
ref="sectionHeaderRef"
data-test="traces-section-header"
class="border-border-default flex h-9 shrink-0 items-center border-b px-[0.4rem]! max-md:h-auto max-md:min-h-9 max-md:flex-wrap max-md:gap-y-1 max-md:py-0.5"
>
@@ -100,7 +98,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="flex-1" />
<!-- Right: Refresh → Insights → rows per page → pagination (same sequence as logs) -->
<div
class="border-card-glass-border rounded-default me-1 inline-flex h-6 items-center overflow-hidden border px-1"
>
@@ -124,18 +121,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon name="wrap-text" size="sm" />
<OTooltip :content="t('search.messageWrapContent')" />
</OButton>
<OButton
variant="outline"
:size="showActionLabels ? 'chip' : 'icon-chip'"
@click.stop="openUnifiedAnalysisDashboard"
data-test="insights-button"
>
<OIcon name="timeline" size="sm" />
<span v-if="showActionLabels" class="whitespace-nowrap">{{
t("volumeInsights.analyzeBtnLabel")
}}</span>
<OTooltip v-if="!showActionLabels" :content="t('volumeInsights.analyzeTooltipTraces')" />
</OButton>
<template v-if="searchObj.meta.resultGrid.showPagination">
<OSelect
:model-value="searchObj.meta.resultGrid.rowsPerPage"
@@ -230,7 +215,6 @@ import {
computed,
defineAsyncComponent,
defineComponent,
onBeforeUnmount,
onMounted,
ref,
watch,
@@ -338,28 +322,10 @@ export default defineComponent({
// nested scrollbar — fixes the double-scrollbar on the traces page.
const scrollContainerRef = ref<HTMLElement | null>(null);
const sectionHeaderRef = ref<HTMLElement | null>(null);
const containerWidth = ref(9999);
let headerResizeObserver: ResizeObserver | null = null;
const showActionLabels = computed(() => containerWidth.value >= 900);
onMounted(() => {
// Restore the wrap choice: a preference that resets on every reload is
// not much of a preference.
searchObj.meta.resultGrid.wrapCells = useLocalWrapTracesContent() === "true";
if (sectionHeaderRef.value) {
containerWidth.value = sectionHeaderRef.value.getBoundingClientRect().width;
headerResizeObserver = new ResizeObserver((entries) => {
containerWidth.value = entries[0]?.contentRect.width ?? 0;
});
headerResizeObserver.observe(sectionHeaderRef.value);
}
});
//Before unmount
onBeforeUnmount(() => {
headerResizeObserver?.disconnect();
});
watch(
@@ -483,8 +449,6 @@ export default defineComponent({
updatedLocalLogFilterField,
metricsDashboardRef,
scrollContainerRef,
sectionHeaderRef,
showActionLabels,
expandRowDetail,
onMetricsTimeRangeSelected,
onMetricsFiltersUpdated,
+936 -2
View File
@@ -45,6 +45,16 @@ vi.mock("@/services/stream", async (importOriginal) => {
});
});
const mockAnomalyList = vi.fn().mockResolvedValue({ data: [] });
vi.mock("@/services/anomaly_detection", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: {
list: (...args: any[]) => mockAnomalyList(...args),
},
});
});
// ---------------------------------------------------------------------------
// Shared reactive searchObj
// ---------------------------------------------------------------------------
@@ -314,6 +324,7 @@ const mockServices: ServiceRow[] = [
function mountServicesCatalog(
options: {
storeOverrides?: Record<string, any>;
stubs?: Record<string, any>;
} = {},
) {
const mockStore = options.storeOverrides
@@ -396,6 +407,7 @@ function mountServicesCatalog(
emits: ["close", "view-traces"],
},
OIcon: false,
...options.stubs,
},
},
});
@@ -1534,7 +1546,7 @@ describe("ServicesCatalog", () => {
// P99 latency warning threshold
// -----------------------------------------------------------------------
describe("P99 warning threshold", () => {
it("should have P99_WARN_NS set to 1 second (1,000,000,000 ns)", async () => {
it("should have P99_WARN_US set to 1 second (1,000,000 µs)", async () => {
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
if (callbacks?.complete) {
callbacks.complete(null, {});
@@ -1544,7 +1556,7 @@ describe("ServicesCatalog", () => {
wrapper = mountServicesCatalog();
await flushPromises();
expect(wrapper.vm.P99_WARN_NS).toBe(1_000_000_000);
expect(wrapper.vm.P99_WARN_US).toBe(1_000_000);
});
});
@@ -2228,4 +2240,926 @@ describe("ServicesCatalog", () => {
});
});
});
describe("request-scoped RED", () => {
const KIND_PRED = "CAST(span_kind AS VARCHAR) IN ('2','5')";
const ROOT_ARM = "OR (reference_parent_span_id IS NULL OR reference_parent_span_id = '')";
const INFER_ARM =
"(NULLIF(infer_service_name, '') IS NOT NULL AND CAST(span_kind AS VARCHAR) IN ('3','4'))";
function schemaWith(...names: string[]) {
return { data: { schema: names.map((name) => ({ name })) } };
}
function decodedSqls(): string[] {
return mockFetchQueryDataWithHttpStream.mock.calls.map((call: any[]) =>
atob(call[0].queryReq.query.sql.replace(/-/g, "+").replace(/_/g, "/").replace(/\./g, "=")),
);
}
function selectItem(sql: string, alias: string): string {
const line = sql.split("\n").find((l) => new RegExp(`\\bAS ${alias},?$`).test(l.trim()));
if (!line) throw new Error(`no select item aliased ${alias}`);
return line;
}
async function loadWithSchema(schema: any): Promise<string> {
mockStreamSchema.mockResolvedValueOnce(schema);
wrapper = mountServicesCatalog();
await flushPromises();
const sqls = decodedSqls();
expect(sqls).toHaveLength(1);
return sqls[0];
}
afterEach(() => {
mockSearchObj.organizationIdentifier = "test-org";
});
it("scopes plain-branch requests to server, consumer and root spans without a WHERE", async () => {
const sql = await loadWithSchema(schemaWith("reference_parent_span_id"));
const requests = selectItem(sql, "total_requests");
expect(requests).toContain("COUNT(*) FILTER (WHERE");
expect(requests).toContain(KIND_PRED);
expect(requests).toContain(`${KIND_PRED} ${ROOT_ARM}`);
expect(sql).not.toContain("= '1'");
expect(sql.replace(/FILTER \(WHERE/g, "")).not.toContain("WHERE");
});
it("omits the root arm when the schema lacks reference_parent_span_id", async () => {
const sql = await loadWithSchema(schemaWith("service_name"));
expect(selectItem(sql, "total_requests")).toContain(`(WHERE (${KIND_PRED}))`);
expect(sql).not.toContain("reference_parent_span_id");
});
it("omits the root arm when the schema fetch fails", async () => {
mockStreamSchema.mockRejectedValueOnce(new Error("schema unavailable"));
wrapper = mountServicesCatalog();
await flushPromises();
const sqls = decodedSqls();
expect(sqls).toHaveLength(1);
expect(selectItem(sqls[0], "total_requests")).toContain(KIND_PRED);
expect(sqls[0]).not.toContain("reference_parent_span_id");
});
it("keeps the CLIENT/PRODUCER infer arm in the infer branch", async () => {
const sql = await loadWithSchema(
schemaWith("infer_service_name", "reference_parent_span_id"),
);
const requests = selectItem(sql, "total_requests");
expect(requests).toContain(INFER_ARM);
expect(requests).toContain(KIND_PRED);
expect(requests).toContain(ROOT_ARM);
expect(selectItem(sql, "error_count")).toContain(INFER_ARM);
expect(sql.split("GROUP BY")[1]).toContain(
"COALESCE(NULLIF(infer_service_name, ''), service_name)",
);
expect(sql.replace(/FILTER \(WHERE/g, "")).not.toContain("WHERE");
});
it("computes errors, avg, max and percentiles over request spans only", async () => {
const sql = await loadWithSchema(schemaWith("reference_parent_span_id"));
const errors = selectItem(sql, "error_count");
expect(errors).toContain("COUNT(*) FILTER (WHERE");
expect(errors).toContain(KIND_PRED);
expect(errors).toContain("span_status = 'ERROR'");
for (const alias of [
"avg_duration_ns",
"max_duration_ns",
"p50_latency_ns",
"p95_latency_ns",
"p99_latency_ns",
]) {
const item = selectItem(sql, alias);
expect(item).toContain("CASE WHEN");
expect(item).toContain(KIND_PRED);
expect(item).toContain(ROOT_ARM);
expect(item).toContain("THEN duration END");
}
expect(selectItem(sql, "avg_duration_ns")).toMatch(/^\s*AVG\(CASE WHEN/);
expect(selectItem(sql, "max_duration_ns")).toMatch(/^\s*MAX\(CASE WHEN/);
});
it("still renders a service with zero request spans, at 0 requests and 0% errors", async () => {
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks.data(null, {
type: "search_response_hits",
content: {
results: {
hits: [
{
service_name: "nightly-cron",
total_requests: 0,
error_count: 0,
error_rate: 0,
avg_duration_ns: null,
max_duration_ns: null,
p50_latency_ns: null,
p95_latency_ns: null,
p99_latency_ns: null,
},
],
},
},
});
callbacks.complete(null, {});
});
const sql = await loadWithSchema(schemaWith("reference_parent_span_id"));
expect(selectItem(sql, "error_rate")).toMatch(
/^\s*CASE WHEN COUNT\(\*\) FILTER .* = 0 THEN 0/,
);
const row = wrapper.vm.services.find((s: any) => s.service_name === "nightly-cron");
expect(row).toMatchObject({
total_requests: 0,
error_count: 0,
error_rate: 0,
p99_latency_ns: 0,
status: "healthy",
});
expect(wrapper.find('[data-test="services-catalog-requests-nightly-cron"]').text()).toBe("0");
});
it("discards a schema fetched for a stream that is no longer selected", async () => {
let release!: (value: any) => void;
mockStreamSchema.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
wrapper = mountServicesCatalog();
await flushPromises();
expect(mockFetchQueryDataWithHttpStream).not.toHaveBeenCalled();
wrapper.vm.streamFilter = "production";
await flushPromises();
release(schemaWith("infer_service_name", "reference_parent_span_id"));
await flushPromises();
const sqls = decodedSqls();
expect(sqls).toHaveLength(1);
expect(sqls[0]).toContain('FROM "production"');
expect(sqls[0]).not.toContain("infer_service_name");
expect(sqls[0]).not.toContain("reference_parent_span_id");
});
it("discards a schema fetched for an organization that is no longer selected", async () => {
let release!: (value: any) => void;
mockStreamSchema.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
wrapper = mountServicesCatalog();
await flushPromises();
mockSearchObj.organizationIdentifier = "other-org";
const reload = wrapper.vm.loadServicesCatalog();
await flushPromises();
release(schemaWith("infer_service_name", "reference_parent_span_id"));
await reload;
await flushPromises();
const sqls = decodedSqls();
expect(sqls).toHaveLength(1);
expect(sqls[0]).not.toContain("infer_service_name");
expect(mockStreamSchema).toHaveBeenLastCalledWith("other-org", "default", "traces");
});
it("stops loading when a superseded schema fetch is discarded and no newer load runs", async () => {
let release!: (value: any) => void;
mockStreamSchema.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
wrapper = mountServicesCatalog();
await flushPromises();
expect(wrapper.vm.isSearching).toBe(true);
mockSearchObj.organizationIdentifier = "other-org";
release(schemaWith("reference_parent_span_id"));
await flushPromises();
expect(mockFetchQueryDataWithHttpStream).not.toHaveBeenCalled();
expect(wrapper.vm.isLoading).toBe(false);
expect(wrapper.vm.isSearching).toBe(false);
});
it("keeps loading when a newer load is still fetching its schema", async () => {
const releases: ((value: any) => void)[] = [];
const pending = () =>
new Promise((resolve) => {
releases.push(resolve);
});
mockStreamSchema.mockImplementationOnce(pending).mockImplementationOnce(pending);
wrapper = mountServicesCatalog();
await flushPromises();
wrapper.vm.streamFilter = "production";
await flushPromises();
releases[0](schemaWith("reference_parent_span_id"));
await flushPromises();
expect(wrapper.vm.isSearching).toBe(true);
releases[1](schemaWith("reference_parent_span_id"));
await flushPromises();
expect(decodedSqls()).toHaveLength(1);
});
function hitsResponse(service: string) {
return {
type: "search_response_hits",
content: { results: { hits: [{ service_name: service, total_requests: 5 }] } },
};
}
it("lets only the newer of two same-stream loads search after their schema fetch", async () => {
let release!: (value: any) => void;
mockStreamSchema.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const callbacks: any[] = [];
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, cb: any) => {
callbacks.push(cb);
});
wrapper = mountServicesCatalog();
await flushPromises();
const reload = wrapper.vm.loadServicesCatalog();
await flushPromises();
release(schemaWith("reference_parent_span_id"));
await reload;
await flushPromises();
callbacks
.reverse()
.forEach((cb, i) => cb.data(null, hitsResponse(i === 0 ? "newer" : "older")));
callbacks.forEach((cb) => cb.complete(null, {}));
await flushPromises();
expect(callbacks).toHaveLength(1);
expect(wrapper.vm.services.map((s: any) => s.service_name)).toEqual(["newer"]);
});
it("ignores stream callbacks from a superseded load", async () => {
const callbacks: any[] = [];
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, cb: any) => {
callbacks.push(cb);
});
wrapper = mountServicesCatalog();
await flushPromises();
const reload = wrapper.vm.loadServicesCatalog();
await flushPromises();
expect(callbacks).toHaveLength(2);
callbacks[0].data(null, hitsResponse("older"));
callbacks[0].complete(null, {});
callbacks[0].error();
await flushPromises();
expect(wrapper.vm.isSearching).toBe(true);
callbacks[1].data(null, hitsResponse("newer"));
callbacks[1].complete(null, {});
await reload;
await flushPromises();
expect(wrapper.vm.services.map((s: any) => s.service_name)).toEqual(["newer"]);
expect(wrapper.vm.isSearching).toBe(false);
});
it("labels latency tooltips in µs and warns on a P99 above 1 s", async () => {
const latencyRow = (name: string, p99: number) => ({
id: name,
service_name: name,
status: "healthy",
total_requests: 10,
error_count: 0,
error_rate: 0,
avg_duration_ns: 1000,
max_duration_ns: p99,
p50_latency_ns: 1000,
p95_latency_ns: p99,
p99_latency_ns: p99,
});
wrapper = mountServicesCatalog({
stubs: {
OTable: {
template: `<div><div v-for="row in data" :key="row.id" :data-test="'latency-row-' + row.service_name"><slot name="cell-p99_latency_ns" :row="row" /></div></div>`,
props: ["data", "columns", "loading"],
},
ServiceCatalogBarCell: {
template: `<span data-test="latency-cell" :data-tooltip="tooltip" :data-variant="variant" />`,
props: ["value", "max", "label", "tooltip", "variant"],
},
},
});
await flushPromises();
wrapper.vm.services = [latencyRow("slow", 1_500_000), latencyRow("fast", 900_000)];
wrapper.vm.isLoading = false;
await flushPromises();
const cell = (name: string) =>
wrapper.find(`[data-test="latency-row-${name}"] [data-test="latency-cell"]`);
expect(cell("slow").attributes("data-variant")).toBe("warning");
expect(cell("fast").attributes("data-variant")).toBe("default");
expect(cell("slow").attributes("data-tooltip")).toBe(`${(1_500_000).toLocaleString()} µs`);
});
it("shows a dash instead of latencies, error rate and a Healthy status for zero-traffic rows", async () => {
const row = (name: string, requests: number) => ({
id: name,
service_name: name,
status: "healthy",
total_requests: requests,
error_count: 0,
error_rate: 0,
avg_duration_ns: requests ? 1000 : 0,
max_duration_ns: requests ? 1000 : 0,
p50_latency_ns: requests ? 1000 : 0,
p95_latency_ns: requests ? 1000 : 0,
p99_latency_ns: requests ? 1000 : 0,
});
const cols = [
"status",
"error_rate",
"p50_latency_ns",
"p95_latency_ns",
"p99_latency_ns",
"avg_duration_ns",
"max_duration_ns",
];
wrapper = mountServicesCatalog({
stubs: {
OTable: {
template: `<div><div v-for="row in data" :key="row.id"><span v-for="c in cols" :key="c" :data-test="'cell-' + c + '-' + row.service_name"><slot :name="'cell-' + c" :row="row" /></span></div></div>`,
props: ["data", "columns", "loading"],
setup: () => ({ cols }),
},
ServiceCatalogBarCell: {
template: `<span data-test="latency-cell">{{ label }}</span>`,
props: ["value", "max", "label", "tooltip", "variant"],
},
},
});
await flushPromises();
wrapper.vm.services = [row("idle", 0), row("busy", 10)];
wrapper.vm.isLoading = false;
await flushPromises();
for (const c of cols) {
expect(wrapper.find(`[data-test="cell-${c}-idle"]`).text()).toBe("—");
expect(wrapper.find(`[data-test="cell-${c}-busy"]`).text()).not.toBe("—");
}
expect(wrapper.find('[data-test="cell-status-busy"]').text()).toBe("Healthy");
});
it("explains the request definition in the Requests header tooltip", async () => {
wrapper = mountServicesCatalog();
await flushPromises();
const requestsColumn = wrapper.vm.tableColumns.find((c: any) => c.id === "total_requests");
expect(requestsColumn.meta.headerTooltip).toBe("Server and consumer spans, plus root spans");
});
});
describe("RED insights strip", () => {
const T = 1_700_000_000_000_000;
const FIVE_MIN_US = 300_000_000;
const MANAGED_TAGS = ["auto:red-insights", "red:p95"];
function anomalyRow(overrides: Record<string, any> = {}) {
return {
_timestamp: T,
anomaly_id: "A1",
anomaly_name: "RED p95 · default · checkout",
stream_name: "default",
deviation_percent: 42.5,
...overrides,
};
}
function anomaliesSchema(...names: string[]) {
return {
data: {
schema: ["anomaly_name", "is_anomaly", "stream_type", ...names].map((name) => ({ name })),
},
};
}
function stubSchemas(anomalies: () => Promise<any>) {
mockStreamSchema.mockImplementation((_org: string, stream: string) =>
stream === "_anomalies" ? anomalies() : Promise.resolve({ data: { schema: [] } }),
);
}
function anomalyCalls(): any[] {
return mockSearchFn.mock.calls.filter((call: any[]) =>
call[0]?.query?.query?.sql?.includes('"_anomalies"'),
);
}
async function mountEnabled(): Promise<void> {
wrapper = mountServicesCatalog({
storeOverrides: {
organizationData: {
organizationSettings: { red_insights_enabled: true },
streams: {},
},
},
});
await flushPromises();
}
beforeEach(() => {
stubSchemas(() => Promise.resolve(anomaliesSchema("is_absence", "direction")));
mockSearchFn.mockResolvedValue({ data: { hits: [anomalyRow()] } });
mockAnomalyList.mockResolvedValue({
data: [{ anomaly_id: "A1", folder_id: "red-folder", tags: MANAGED_TAGS }],
});
});
afterEach(() => {
mockStreamSchema.mockReset();
mockStreamSchema.mockResolvedValue({ data: { schema: [] } });
mockSearchFn.mockReset();
mockSearchFn.mockResolvedValue({ data: {} });
mockAnomalyList.mockReset();
mockAnomalyList.mockResolvedValue({ data: [] });
mockSearchObj.data.datetime.type = "relative";
});
it("does not query _anomalies when the setting is off", async () => {
wrapper = mountServicesCatalog();
await flushPromises();
expect(anomalyCalls()).toHaveLength(0);
expect(wrapper.find('[data-test="services-catalog-insights"]').exists()).toBe(false);
});
it("queries managed anomalies over the catalog time range, keeping below-direction rate ones", async () => {
mockSearchObj.data.datetime.type = "absolute";
await mountEnabled();
const calls = anomalyCalls();
expect(calls).toHaveLength(1);
const { sql, start_time, end_time } = calls[0][0].query.query;
expect(sql).toContain("stream_type = 'traces'");
expect(sql).toContain("anomaly_name LIKE 'RED %'");
expect(sql).toContain("is_anomaly = true");
expect(sql).toContain("(is_absence IS NULL OR is_absence = false)");
expect(sql).toContain("(anomaly_name LIKE 'RED rate %' OR direction = 'above')");
const catalogQuery = mockFetchQueryDataWithHttpStream.mock.calls[0][0].queryReq.query;
expect([start_time, end_time]).toEqual([catalogQuery.start_time, catalogQuery.end_time]);
});
it("leaves out filters on columns the _anomalies stream has never written", async () => {
stubSchemas(() => Promise.resolve(anomaliesSchema()));
await mountEnabled();
const { sql } = anomalyCalls()[0][0].query.query;
expect(sql).not.toContain("is_absence");
expect(sql).not.toContain("direction");
expect(sql).toContain("anomaly_name LIKE 'RED rate %'");
});
it("renders each anomaly once with its traces and Charts links", async () => {
mockSearchFn.mockResolvedValue({ data: { hits: [anomalyRow(), anomalyRow()] } });
await mountEnabled();
const strip = wrapper.find('[data-test="services-catalog-insights"]');
expect(strip.exists()).toBe(true);
const entries = wrapper.findAll('[data-test^="services-catalog-insight-entry-"]');
expect(entries).toHaveLength(1);
expect(entries[0].text()).toContain("checkout");
expect(entries[0].text()).toContain("p95");
expect(entries[0].text()).toContain("42.5%");
await wrapper.find('[data-test="services-catalog-insight-traces-0"]').trigger("click");
expect(wrapper.emitted("view-traces")?.[0]?.[0]).toEqual({
serviceName: "checkout",
stream: "default",
mode: "traces",
timeRange: { startTime: T, endTime: T + FIVE_MIN_US },
});
await wrapper.find('[data-test="services-catalog-insight-charts-0"]').trigger("click");
expect(mockRouterPush).toHaveBeenCalledWith({
name: "alertDetail",
params: { alert_id: "A1" },
query: { org_identifier: "test-org", folder: "red-folder" },
});
});
it("links traces on the detector's stream, not the catalog's", async () => {
mockSearchFn.mockResolvedValue({
data: {
hits: [
anomalyRow({
anomaly_name: "RED rate · team∕edge · api∕v1",
stream_name: "team/edge",
}),
],
},
});
await mountEnabled();
await wrapper.find('[data-test="services-catalog-insight-traces-0"]').trigger("click");
expect(wrapper.emitted("view-traces")?.[0]?.[0]).toMatchObject({
serviceName: "api/v1",
stream: "team/edge",
});
});
it("omits the Charts link but keeps the strip when the config list is forbidden", async () => {
mockAnomalyList.mockRejectedValue({ response: { status: 403 } });
await mountEnabled();
expect(wrapper.find('[data-test="services-catalog-insights"]').exists()).toBe(true);
expect(wrapper.find('[data-test="services-catalog-insight-traces-0"]').exists()).toBe(true);
expect(wrapper.find('[data-test="services-catalog-insight-charts-0"]').exists()).toBe(false);
});
it("is hidden when there are no anomalies", async () => {
mockSearchFn.mockResolvedValue({ data: { hits: [] } });
await mountEnabled();
expect(wrapper.find('[data-test="services-catalog-insights"]').exists()).toBe(false);
});
it("is hidden when the _anomalies search is forbidden", async () => {
mockSearchFn.mockRejectedValue({ response: { status: 403 } });
await mountEnabled();
expect(wrapper.find('[data-test="services-catalog-insights"]').exists()).toBe(false);
});
it("is hidden, without a search, when the _anomalies stream does not exist", async () => {
stubSchemas(() => Promise.reject({ response: { status: 404 } }));
await mountEnabled();
expect(anomalyCalls()).toHaveLength(0);
expect(wrapper.find('[data-test="services-catalog-insights"]').exists()).toBe(false);
});
});
describe("same-named entities", () => {
const metrics = {
error_count: 0,
error_rate: 0,
avg_duration_ns: 0,
max_duration_ns: 0,
p50_latency_ns: 0,
p95_latency_ns: 0,
p99_latency_ns: 0,
};
function mockHits(hits: any[]) {
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks?.data?.(null, {
type: "search_response_hits",
content: { results: { hits: hits.map((h) => ({ ...metrics, ...h })) } },
});
callbacks?.complete?.(null, {});
});
}
const cellRenderingTable = {
template: `
<div data-test="services-catalog-table">
<div v-for="row in data" :key="row.id">
<slot name="cell-service_name" :row="row" />
</div>
</div>
`,
props: ["data"],
};
it("drops inferred rows named like an instrumented service instead of listing it again", async () => {
mockHits([
{ service_name: "email-service", _is_real_service: 1, total_requests: 14152 },
{
service_name: "email-service",
_infer_service_name: "email-service",
_infer_service_system: "http",
_infer_service_type: "external",
_is_real_service: 0,
total_requests: 14129,
},
{
service_name: "email-service",
_infer_service_name: "email-service",
_infer_service_type: "rpc",
_is_real_service: 0,
total_requests: 13937,
},
{
service_name: "google.com",
_infer_service_name: "google.com",
_infer_service_system: "http",
_infer_service_type: "external",
_is_real_service: 0,
total_requests: 10,
},
]);
wrapper = mountServicesCatalog();
await flushPromises();
const emailRows = wrapper.vm.services.filter((s: any) => s.service_name === "email-service");
expect(emailRows).toHaveLength(1);
expect(emailRows[0].total_requests).toBe(14152);
expect(wrapper.vm.categoryCounts).toEqual({
all: 2,
service: 1,
datastore: 0,
queue: 0,
external: 1,
rpc: 0,
});
});
it("labels same-named dependencies with their system", async () => {
mockHits([
{
service_name: "orders",
_infer_service_name: "orders",
_infer_service_system: "postgresql",
_infer_service_type: "database",
total_requests: 14212,
},
{
service_name: "orders",
_infer_service_name: "orders",
_infer_service_system: "kafka",
_infer_service_type: "queue",
total_requests: 14165,
},
{
service_name: "orders",
_infer_service_name: "orders",
_infer_service_system: "mysql",
_infer_service_type: "database",
total_requests: 14078,
},
]);
wrapper = mountServicesCatalog({ stubs: { OTable: cellRenderingTable } });
await flushPromises();
wrapper.vm.onTypeFilterChange("datastore");
await flushPromises();
const labels = wrapper
.findAll('[data-test="services-catalog-service-system"]')
.map((l) => l.text());
expect(labels.sort()).toEqual(["mysql", "postgresql"]);
wrapper.vm.onTypeFilterChange("all");
await flushPromises();
expect(
wrapper.findAll('[data-test="services-catalog-service-system"]').map((l) => l.text()),
).toHaveLength(3);
});
function mockBatches(batches: any[][]) {
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
for (const hits of batches) {
callbacks?.data?.(null, {
type: "search_response_hits",
content: { results: { hits: hits.map((h) => ({ ...metrics, ...h })) } },
});
}
callbacks?.complete?.(null, {});
});
}
const inferredEmailExternal = {
service_name: "email-service",
_infer_service_name: "email-service",
_infer_service_system: "http",
_infer_service_type: "external",
_is_real_service: 0,
total_requests: 14129,
};
it("drops inferred rows that arrive in an earlier batch than the real row", async () => {
mockBatches([
[
inferredEmailExternal,
{
service_name: "email-service",
_infer_service_name: "email-service",
_infer_service_type: "rpc",
_is_real_service: 0,
total_requests: 13937,
},
],
[{ service_name: "email-service", _is_real_service: 1, total_requests: 14152 }],
]);
wrapper = mountServicesCatalog();
await flushPromises();
const emailRows = wrapper.vm.services.filter((s: any) => s.service_name === "email-service");
expect(emailRows).toHaveLength(1);
expect(emailRows[0].is_real_service).toBe(1);
});
it("does not let a real row from a previous search hide this search's inferred row", async () => {
mockHits([{ service_name: "email-service", _is_real_service: 1, total_requests: 14152 }]);
wrapper = mountServicesCatalog();
await flushPromises();
mockHits([inferredEmailExternal]);
await wrapper.vm.loadServicesCatalog();
await flushPromises();
const inferred = wrapper.vm.services.filter(
(s: any) => s.service_name === "email-service" && s.infer_service_name,
);
expect(inferred).toHaveLength(1);
expect(inferred[0].infer_service_type).toBe("external");
expect(wrapper.vm.services.some((s: any) => s.is_real_service === 1)).toBe(false);
});
it("keeps a datastore or queue that shares a real service's name", async () => {
mockHits([
{ service_name: "orders", _is_real_service: 1, total_requests: 500 },
{
service_name: "orders",
_infer_service_name: "orders",
_infer_service_system: "postgresql",
_infer_service_type: "database",
_is_real_service: 0,
total_requests: 400,
},
{
service_name: "orders",
_infer_service_name: "orders",
_infer_service_system: "kafka",
_infer_service_type: "queue",
_is_real_service: 0,
total_requests: 300,
},
]);
wrapper = mountServicesCatalog();
await flushPromises();
expect(wrapper.vm.services).toHaveLength(3);
expect(wrapper.vm.categoryCounts).toMatchObject({ service: 1, datastore: 1, queue: 1 });
});
it("shows no system label on an instrumented row", async () => {
mockHits([
{ service_name: "checkout", _is_real_service: 1, total_requests: 500 },
{
service_name: "orders-db",
_infer_service_name: "orders-db",
_infer_service_system: "postgresql",
_infer_service_type: "database",
_is_real_service: 0,
total_requests: 400,
},
]);
const rowTable = {
template: `
<div data-test="services-catalog-table">
<div v-for="row in data" :key="row.id" :data-row="row.service_name">
<slot name="cell-service_name" :row="row" />
</div>
</div>
`,
props: ["data"],
};
wrapper = mountServicesCatalog({ stubs: { OTable: rowTable } });
await flushPromises();
wrapper.vm.onTypeFilterChange("all");
await flushPromises();
const label = '[data-test="services-catalog-service-system"]';
const checkout = wrapper.find('[data-row="checkout"]');
expect(checkout.exists()).toBe(true);
expect(checkout.find(label).exists()).toBe(false);
expect(wrapper.find('[data-row="orders-db"]').find(label).text()).toBe("postgresql");
});
const realEmail = { service_name: "email-service", _is_real_service: 1, total_requests: 14152 };
function hitsResponse(hits: any[]) {
return {
type: "search_response_hits",
content: { results: { hits: hits.map((h) => ({ ...metrics, ...h })) } },
};
}
it("keeps the previous search's rows on screen until the new search's first batch", async () => {
mockHits([realEmail]);
wrapper = mountServicesCatalog();
await flushPromises();
mockFetchQueryDataWithHttpStream.mockImplementation(() => {});
await wrapper.vm.loadServicesCatalog();
await flushPromises();
expect(wrapper.vm.services.map((s: any) => s.service_name)).toEqual(["email-service"]);
});
it("clears the previous search's rows when the new search finds nothing", async () => {
mockHits([realEmail]);
wrapper = mountServicesCatalog();
await flushPromises();
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks?.complete?.(null, {});
});
await wrapper.vm.loadServicesCatalog();
await flushPromises();
expect(wrapper.vm.services).toEqual([]);
});
it("ignores batches from a superseded search", async () => {
const calls: any[] = [];
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
calls.push(callbacks);
});
wrapper = mountServicesCatalog();
await flushPromises();
await wrapper.vm.loadServicesCatalog();
await flushPromises();
const [superseded, current] = calls.slice(-2);
current.data(null, hitsResponse([inferredEmailExternal]));
superseded.data(null, hitsResponse([realEmail]));
superseded.complete(null, {});
current.complete(null, {});
await flushPromises();
const emailRows = wrapper.vm.services.filter((s: any) => s.service_name === "email-service");
expect(emailRows).toHaveLength(1);
expect(emailRows[0].infer_service_type).toBe("external");
});
it("closes the side panel when its row is dropped as a duplicate", async () => {
mockHits([inferredEmailExternal]);
wrapper = mountServicesCatalog();
await flushPromises();
wrapper.vm.handleRowClick(wrapper.vm.services[0]);
expect(wrapper.vm.showSidePanel).toBe(true);
mockHits([realEmail, inferredEmailExternal]);
await wrapper.vm.loadServicesCatalog();
await flushPromises();
expect(wrapper.vm.showSidePanel).toBe(false);
expect(wrapper.vm.selectedServiceRow).toBeNull();
});
it("keeps the side panel open when a new search still has its row", async () => {
mockHits([realEmail]);
wrapper = mountServicesCatalog();
await flushPromises();
wrapper.vm.handleRowClick(wrapper.vm.services[0]);
await wrapper.vm.loadServicesCatalog();
await flushPromises();
expect(wrapper.vm.showSidePanel).toBe(true);
expect(wrapper.vm.selectedServiceRow?.service_name).toBe("email-service");
});
it("keeps the previous rows through a metadata event and replaces them on the first hits", async () => {
mockHits([realEmail]);
wrapper = mountServicesCatalog();
await flushPromises();
let callbacks: any;
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, cb: any) => {
callbacks = cb;
});
await wrapper.vm.loadServicesCatalog();
await flushPromises();
callbacks.data(null, {
type: "search_response_metadata",
content: { results: { hits: [], total: 1, took: 3 } },
});
await flushPromises();
expect(wrapper.vm.services.map((s: any) => s.service_name)).toEqual(["email-service"]);
callbacks.data(null, hitsResponse([inferredEmailExternal]));
await flushPromises();
expect(wrapper.vm.services).toHaveLength(1);
expect(wrapper.vm.services[0].infer_service_type).toBe("external");
});
it("closes the side panel when a batch drops its row and the search then errors", async () => {
mockHits([inferredEmailExternal]);
wrapper = mountServicesCatalog();
await flushPromises();
wrapper.vm.handleRowClick(wrapper.vm.services[0]);
expect(wrapper.vm.showSidePanel).toBe(true);
mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
callbacks.data(null, hitsResponse([realEmail]));
callbacks.error(null, { type: "error", content: { message: "boom" } });
});
await wrapper.vm.loadServicesCatalog();
await flushPromises();
expect(wrapper.vm.showSidePanel).toBe(false);
expect(wrapper.vm.selectedServiceRow).toBeNull();
});
});
});
+372 -86
View File
@@ -210,6 +210,52 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<div
v-if="insights.length > 0"
class="border-border-default bg-surface-panel rounded-surface mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 border px-3 py-1.5 text-xs"
data-test="services-catalog-insights"
>
<span class="text-text-heading font-semibold">{{
t("traces.servicesCatalog.insights.title")
}}</span>
<div
v-for="(entry, i) in insights"
:key="entry.key"
class="flex items-center gap-1.5"
:data-test="`services-catalog-insight-entry-${i}`"
>
<span class="font-medium">{{ entry.service }}</span>
<span class="text-text-secondary">{{
t(`traces.servicesCatalog.insights.signals.${entry.signal}`)
}}</span>
<span class="text-text-secondary tabular-nums">{{
formatInsightTime(entry.timestampUs)
}}</span>
<span class="text-text-secondary tabular-nums">{{
t("traces.servicesCatalog.insights.scoreAbove", {
percent: entry.deviationPercent.toFixed(1),
})
}}</span>
<OButton
variant="ghost-primary"
size="xs"
:data-test="`services-catalog-insight-traces-${i}`"
@click="openInsightTraces(entry)"
>
{{ t("traces.servicesCatalog.insights.viewTraces") }}
</OButton>
<OButton
v-if="entry.folder"
variant="ghost-primary"
size="xs"
:data-test="`services-catalog-insight-charts-${i}`"
@click="openInsightCharts(entry)"
>
{{ t("traces.servicesCatalog.insights.viewCharts") }}
</OButton>
</div>
</div>
<!-- Body: left rail (entity-type filter) + table — mirrors the Dashboards
folder-rail + table layout (panel bg + vertical separator, 230px). -->
<div class="flex min-h-0 flex-1 max-md:flex-col">
@@ -310,9 +356,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@row-click="(row) => handleRowClick(row)"
@sort-change="(p) => handleSortChange(p.column)"
>
<!-- Status badge -->
<!-- Status badge; a row with no requests has no health to report -->
<template #cell-status="{ row }">
<span v-if="row.total_requests === 0" class="text-text-muted">—</span>
<OTag
v-else
type="serviceStatus"
:value="row.status"
:data-test="`services-catalog-status-${row.service_name}`"
@@ -323,17 +371,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Service name via TraceServiceCell -->
<template #cell-service_name="{ row }">
<TraceServiceCell
:item="row"
class="cursor-pointer"
:data-test="`services-catalog-service-link-${row.service_name}`"
@click.stop="handleRowClick(row)"
/>
<div class="flex min-w-0 items-center gap-1.5">
<TraceServiceCell
:item="row"
class="min-w-0 cursor-pointer"
:data-test="`services-catalog-service-link-${row.service_name}`"
@click.stop="handleRowClick(row)"
/>
<span
v-if="row.infer_service_name && row.infer_service_system"
class="text-text-secondary max-w-24 shrink-0 truncate text-xs"
:title="row.infer_service_system"
data-test="services-catalog-service-system"
>{{ row.infer_service_system }}</span
>
</div>
</template>
<!-- Error rate with progress bar -->
<template #cell-error_rate="{ row }">
<span v-if="row.total_requests === 0" class="text-text-muted">—</span>
<ServiceCatalogBarCell
v-else
:value="row.error_rate"
:max="columnMaxes.error_rate"
:label="raw(formatPercent(row.error_rate))"
@@ -361,48 +420,58 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Latency / duration columns -->
<template #cell-p50_latency_ns="{ row }">
<span v-if="row.total_requests === 0" class="text-text-muted">—</span>
<ServiceCatalogBarCell
v-else
:value="row.p50_latency_ns"
:max="columnMaxes.p50_latency_ns"
:label="raw(formatLat(row.p50_latency_ns))"
:tooltip="raw(row.p50_latency_ns.toLocaleString() + ' ns')"
:tooltip="raw(row.p50_latency_ns.toLocaleString() + ' µs')"
/>
</template>
<template #cell-p95_latency_ns="{ row }">
<span v-if="row.total_requests === 0" class="text-text-muted">—</span>
<ServiceCatalogBarCell
v-else
:value="row.p95_latency_ns"
:max="columnMaxes.p95_latency_ns"
:label="raw(formatLat(row.p95_latency_ns))"
:tooltip="raw(row.p95_latency_ns.toLocaleString() + ' ns')"
:tooltip="raw(row.p95_latency_ns.toLocaleString() + ' µs')"
/>
</template>
<template #cell-p99_latency_ns="{ row }">
<span v-if="row.total_requests === 0" class="text-text-muted">—</span>
<ServiceCatalogBarCell
v-else
:value="row.p99_latency_ns"
:max="columnMaxes.p99_latency_ns"
:label="raw(formatLat(row.p99_latency_ns))"
:tooltip="raw(row.p99_latency_ns.toLocaleString() + ' ns')"
:variant="row.p99_latency_ns > P99_WARN_NS ? 'warning' : 'default'"
:tooltip="raw(row.p99_latency_ns.toLocaleString() + ' µs')"
:variant="row.p99_latency_ns > P99_WARN_US ? 'warning' : 'default'"
/>
</template>
<template #cell-avg_duration_ns="{ row }">
<span v-if="row.total_requests === 0" class="text-text-muted">—</span>
<ServiceCatalogBarCell
v-else
:value="row.avg_duration_ns"
:max="columnMaxes.avg_duration_ns"
:label="raw(formatLat(row.avg_duration_ns))"
:tooltip="raw(row.avg_duration_ns.toLocaleString() + ' ns')"
:tooltip="raw(row.avg_duration_ns.toLocaleString() + ' µs')"
/>
</template>
<template #cell-max_duration_ns="{ row }">
<span v-if="row.total_requests === 0" class="text-text-muted">—</span>
<ServiceCatalogBarCell
v-else
:value="row.max_duration_ns"
:max="columnMaxes.max_duration_ns"
:label="raw(formatLat(row.max_duration_ns))"
:tooltip="raw(row.max_duration_ns.toLocaleString() + ' ns')"
:tooltip="raw(row.max_duration_ns.toLocaleString() + ' µs')"
/>
</template>
</OTable>
@@ -460,6 +529,11 @@ import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import ServicesCatalogNoDataState from "./ServicesCatalogNoDataState.vue";
import { resolveTraceStream } from "@/utils/traces/streamSelection";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import searchService from "@/services/search";
import { anomalyConfigsQuery } from "@/services/anomaly_detection.queries";
import { timestampToTimezoneDate } from "@/utils/timezone";
const { t } = useI18nTyped();
const { isMobile } = useBreakpoint();
@@ -468,6 +542,8 @@ const catalogContainerRef = ref<HTMLElement | null>(null);
const { searchObj } = useTraces();
const { getStreams } = useStreams(t);
const { fetchQueryDataWithHttpStream, cancelStreamQueryBasedOnRequestId } = useHttpStreaming();
const store = useStore();
const router = useRouter();
const emit = defineEmits<{
"view-traces": [data: string | Record<string, any>];
@@ -475,8 +551,16 @@ const emit = defineEmits<{
"jump-to-stream-data": [fromUs: number, toUs: number];
}>();
// p99 > 1 second triggers the orange highlight
const P99_WARN_NS = 1_000_000_000;
// Latencies are in µs, so this is a p99 above 1 second.
const P99_WARN_US = 1_000_000;
const RED_MANAGED_TAG = "auto:red-insights";
const RED_NAME_PATTERN = /^RED (rate|errors|p95) · /;
const RED_NAME_SEPARATOR = " · ";
// Alert names forbid "/", so the backend writes U+2215 in its place.
const RED_NAME_SLASH = "\u2215";
const RED_BUCKET_US = 300_000_000;
const MAX_INSIGHTS = 5;
// Stream filter — synced from traces page selected stream
const tracesStream = searchObj.data.stream?.selectedStream?.value || "";
@@ -556,17 +640,30 @@ const rowsPerPage = ref(25);
const rowsPerPageOptions = [10, 25, 50, 100];
const sortBy = ref<string>("status");
const sortOrder = ref<"asc" | "desc">("desc");
/**
* Tri-state cache for whether the current stream's schema contains the `infer_service_name` column.
*
* - `null` — not yet checked for the current stream; triggers a schema API call on next load.
* - `true` — column exists; queries will use `infer_service_name` for service grouping.
* - `false` — column absent; queries will fall back to `service_name` only.
*
* The value is reset to `null` whenever the stream filter changes so that the next
* `loadServicesCatalog()` call re-validates against the new stream's schema.
*/
const hasInferColumns = ref<boolean | null>(null);
interface StreamSchemaFlags {
org: string;
stream: string;
hasInferColumns: boolean;
hasParentColumn: boolean;
}
const schemaFlags = ref<StreamSchemaFlags | null>(null);
interface RedInsight {
key: string;
anomalyId: string;
signal: string;
stream: string;
service: string;
timestampUs: number;
deviationPercent: number;
folder?: string;
}
const insights = ref<RedInsight[]>([]);
let insightsRequest = 0;
let catalogLoad = 0;
const redInsightsEnabled = computed(
() => store.state.organizationData?.organizationSettings?.red_insights_enabled === true,
);
// OTable owns pagination internally; `currentPage` is retained only as the
// "reset to page 1 on sort/filter change" signal the tests assert against.
@@ -650,7 +747,7 @@ const tableColumns = computed<OTableColumnDef<ServiceRow>[]>(() => [
sortable: true,
resizable: true,
size: 110,
meta: { align: "right" },
meta: { align: "right", headerTooltip: t("traces.servicesCatalog.columns.requestsTooltip") },
},
{
id: "error_count",
@@ -955,9 +1052,203 @@ const onStreamFilterChange = (stream: SelectModelValue) => {
emit("request:stream-change", String(stream ?? ""));
};
async function fetchSchemaFlags(org: string, stream: string): Promise<StreamSchemaFlags> {
try {
const schemaPayload = await queryClient.fetchQuery(streamSchemaQuery(org, stream, "traces"));
const schemaFields: any[] = schemaPayload?.schema || schemaPayload?.fields || [];
const has = (name: string) => schemaFields.some((f: any) => f.name === name);
return {
org,
stream,
hasInferColumns: has("infer_service_name"),
hasParentColumn: has("reference_parent_span_id"),
};
} catch {
return { org, stream, hasInferColumns: false, hasParentColumn: false };
}
}
// Same request definition as red_insights.rs request_predicate; the service graph counts only internal roots.
function requestPredicate(hasParentColumn: boolean): string {
const kinds = "CAST(span_kind AS VARCHAR) IN ('2','5')";
if (!hasParentColumn) return `(${kinds})`;
// Any root counts, whatever its kind: a trace that starts at a client span still entered the system there.
return `(${kinds} OR (reference_parent_span_id IS NULL OR reference_parent_span_id = ''))`;
}
// Conditional aggregation, not WHERE, so services with no request spans still get a row.
function redAggregates(pred: string): string {
const requests = `COUNT(*) FILTER (WHERE ${pred})`;
const errors = `COUNT(*) FILTER (WHERE ${pred} AND span_status = 'ERROR')`;
const duration = `CASE WHEN ${pred} THEN duration END`;
return [
`${requests} AS total_requests`,
`${errors} AS error_count`,
`CASE WHEN ${requests} = 0 THEN 0 ELSE CAST(${errors} AS DOUBLE) / CAST(${requests} AS DOUBLE) * 100 END AS error_rate`,
`AVG(${duration}) AS avg_duration_ns`,
`MAX(${duration}) AS max_duration_ns`,
`approx_percentile_cont(${duration}, 0.5) AS p50_latency_ns`,
`approx_percentile_cont(${duration}, 0.95) AS p95_latency_ns`,
`approx_percentile_cont(${duration}, 0.99) AS p99_latency_ns`,
]
.map((item) => ` ${item}`)
.join(",\n");
}
function catalogSql(streamName: string, flags: StreamSchemaFlags): string {
const requestPred = requestPredicate(flags.hasParentColumn);
if (!flags.hasInferColumns) {
return `SELECT
service_name,
${redAggregates(requestPred)}
FROM "${streamName}"
GROUP BY service_name
ORDER BY total_requests DESC`;
}
// infer_service_name is only set on CLIENT and PRODUCER spans (traces/inferred.rs).
const inferPred = `((NULLIF(infer_service_name, '') IS NOT NULL AND CAST(span_kind AS VARCHAR) IN ('3','4')) OR ${requestPred})`;
return `SELECT
COALESCE(NULLIF(infer_service_name, ''), service_name) AS service_name,
NULLIF(infer_service_name, '') AS _infer_service_name,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_system, '') END AS _infer_service_system,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_type, '') END AS _infer_service_type,
MAX(CASE WHEN service_name IS NOT NULL AND (infer_service_name IS NULL OR infer_service_name = '') THEN 1 ELSE 0 END) AS _is_real_service,
${redAggregates(inferPred)}
FROM "${streamName}"
GROUP BY
COALESCE(NULLIF(infer_service_name, ''), service_name),
NULLIF(infer_service_name, ''),
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_system, '') END,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_type, '') END
ORDER BY total_requests DESC`;
}
function parseRedInsight(row: any): RedInsight | null {
const name = String(row.anomaly_name ?? "");
const stream = String(row.stream_name ?? "");
const match = RED_NAME_PATTERN.exec(name);
if (!match || !stream) return null;
const prefix = match[0] + stream.replaceAll("/", RED_NAME_SLASH) + RED_NAME_SEPARATOR;
if (!name.startsWith(prefix)) return null;
const timestampUs = Number(row._timestamp);
return {
key: `${row.anomaly_id}:${timestampUs}`,
anomalyId: String(row.anomaly_id),
signal: match[1],
stream,
service: name.slice(prefix.length).replaceAll(RED_NAME_SLASH, "/"),
timestampUs,
deviationPercent: Number(row.deviation_percent ?? 0),
};
}
// Overlapping detection windows re-score a bucket, so the same anomaly can arrive twice.
function toInsights(hits: any[]): RedInsight[] {
const seen = new Set<string>();
const out: RedInsight[] = [];
for (const hit of hits) {
const entry = parseRedInsight(hit);
if (!entry || seen.has(entry.key)) continue;
seen.add(entry.key);
out.push(entry);
if (out.length === MAX_INSIGHTS) break;
}
return out;
}
// A filter on a column the stream never wrote fails the whole query, so it is added per schema.
function insightsSql(columns: Set<string>): string {
const clauses = ["stream_type = 'traces'", "anomaly_name LIKE 'RED %'", "is_anomaly = true"];
if (columns.has("is_absence")) clauses.push("(is_absence IS NULL OR is_absence = false)");
// Rate is watched in both directions; errors and latency only matter going up.
clauses.push(
columns.has("direction")
? "(anomaly_name LIKE 'RED rate %' OR direction = 'above')"
: "anomaly_name LIKE 'RED rate %'",
);
return `SELECT _timestamp, anomaly_id, anomaly_name, stream_name, deviation_percent FROM "_anomalies" WHERE ${clauses.join(" AND ")} ORDER BY _timestamp DESC LIMIT 100`;
}
async function anomaliesColumns(org: string): Promise<Set<string> | null> {
try {
const payload = await queryClient.fetchQuery(streamSchemaQuery(org, "_anomalies", "logs"));
const fields: any[] = payload?.schema || payload?.fields || [];
const names = new Set(fields.map((f: any) => String(f.name)));
return names.has("anomaly_name") ? names : null;
} catch {
return null;
}
}
// The config list needs alert-folder permission, which the job-created folder grants only to admins.
async function managedFolders(org: string): Promise<Map<string, string>> {
try {
const configs = await queryClient.fetchQuery(anomalyConfigsQuery(org));
return new Map(
configs
.filter((c: any) => (c.tags ?? []).includes(RED_MANAGED_TAG) && c.folder_id)
.map((c: any) => [String(c.anomaly_id), String(c.folder_id)]),
);
} catch {
return new Map();
}
}
async function loadInsights() {
const request = ++insightsRequest;
const org = searchObj.organizationIdentifier;
const columns = redInsightsEnabled.value ? await anomaliesColumns(org) : null;
if (request !== insightsRequest) return;
if (!columns) {
insights.value = [];
return;
}
const { start_time, end_time } = getTimeRange();
try {
const res = await searchService.search(
{
org_identifier: org,
query: {
query: { sql: insightsSql(columns), start_time, end_time, from: 0, size: 100 },
},
page_type: "logs",
},
"ui",
);
const entries = toInsights(res?.data?.hits ?? []);
const folders = entries.length ? await managedFolders(org) : new Map<string, string>();
if (request !== insightsRequest) return;
insights.value = entries.map((entry) => ({ ...entry, folder: folders.get(entry.anomalyId) }));
} catch {
if (request === insightsRequest) insights.value = [];
}
}
function formatInsightTime(timestampUs: number): string {
return timestampToTimezoneDate(timestampUs / 1000, store.state.timezone, "yyyy-MM-dd HH:mm");
}
function openInsightTraces(entry: RedInsight) {
emit("view-traces", {
serviceName: entry.service,
stream: entry.stream,
mode: "traces",
timeRange: { startTime: entry.timestampUs, endTime: entry.timestampUs + RED_BUCKET_US },
});
}
function openInsightCharts(entry: RedInsight) {
router.push({
name: "alertDetail",
params: { alert_id: entry.anomalyId },
query: { org_identifier: searchObj.organizationIdentifier, folder: entry.folder },
});
}
async function loadServicesCatalog() {
const streamName = streamFilter.value?.replaceAll('"', "");
if (!streamName) return;
void loadInsights();
if (availableStreams.value.length && !availableStreams.value.includes(streamName)) {
return;
@@ -974,63 +1265,53 @@ async function loadServicesCatalog() {
// stay on screen while the next search runs rather than the panel emptying.
isLoading.value = services.value.length === 0;
isSearching.value = true;
const load = ++catalogLoad;
const { start_time, end_time } = getTimeRange();
// Check stream schema for infer_service_name column (cache result per stream)
if (hasInferColumns.value === null) {
try {
const org = searchObj.organizationIdentifier;
const schemaPayload = await queryClient.fetchQuery(
streamSchemaQuery(org, streamName, "traces"),
);
const schemaFields = schemaPayload?.schema || schemaPayload?.fields || [];
hasInferColumns.value = schemaFields.some((f: any) => f.name === "infer_service_name");
} catch {
// If schema check fails, default to false (use service_name only)
hasInferColumns.value = false;
const org = searchObj.organizationIdentifier;
let flags = schemaFlags.value;
if (!flags || flags.org !== org || flags.stream !== streamName) {
flags = await fetchSchemaFlags(org, streamName);
// A newer load owns the catalog once it started or the org or stream changed during the fetch.
if (
load !== catalogLoad ||
org !== searchObj.organizationIdentifier ||
streamName !== streamFilter.value?.replaceAll('"', "")
) {
// A newer load that got this far owns the flags; otherwise nothing would ever clear them.
if (load === catalogLoad) {
isLoading.value = false;
isSearching.value = false;
}
return;
}
schemaFlags.value = flags;
}
const sql = catalogSql(streamName, flags);
// Build SQL: use infer_service_name when the column exists in the schema
const useInfer = hasInferColumns.value;
const sql = useInfer
? `SELECT
COALESCE(NULLIF(infer_service_name, ''), service_name) AS service_name,
NULLIF(infer_service_name, '') AS _infer_service_name,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_system, '') END AS _infer_service_system,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_type, '') END AS _infer_service_type,
MAX(CASE WHEN service_name IS NOT NULL AND (infer_service_name IS NULL OR infer_service_name = '') THEN 1 ELSE 0 END) AS _is_real_service,
COUNT(*) AS total_requests,
SUM(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) AS error_count,
CAST(SUM(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) AS DOUBLE) / CAST(COUNT(*) AS DOUBLE) * 100 AS error_rate,
AVG(duration) AS avg_duration_ns,
MAX(duration) AS max_duration_ns,
approx_percentile_cont(duration, 0.5) AS p50_latency_ns,
approx_percentile_cont(duration, 0.95) AS p95_latency_ns,
approx_percentile_cont(duration, 0.99) AS p99_latency_ns
FROM "${streamName}"
GROUP BY
COALESCE(NULLIF(infer_service_name, ''), service_name),
NULLIF(infer_service_name, ''),
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_system, '') END,
CASE WHEN NULLIF(infer_service_name, '') IS NULL THEN NULL ELSE NULLIF(infer_service_type, '') END
ORDER BY total_requests DESC`
: `SELECT
service_name,
COUNT(*) AS total_requests,
SUM(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) AS error_count,
CAST(SUM(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) AS DOUBLE) / CAST(COUNT(*) AS DOUBLE) * 100 AS error_rate,
AVG(duration) AS avg_duration_ns,
MAX(duration) AS max_duration_ns,
approx_percentile_cont(duration, 0.5) AS p50_latency_ns,
approx_percentile_cont(duration, 0.95) AS p95_latency_ns,
approx_percentile_cont(duration, 0.99) AS p99_latency_ns
FROM "${streamName}"
GROUP BY service_name
ORDER BY total_requests DESC`;
currentTraceId = generateTraceContext().traceId;
const traceId = generateTraceContext().traceId;
currentTraceId = traceId;
// Owned by this request so a superseded search cannot leak rows or real names into the current one.
const rows = new Map<string, ServiceRow>();
const realNames = new Set<string>();
const publish = () => {
// Inferred HTTP/RPC rows named like an instrumented service are its callers' view of the same calls; datastores and queues are distinct backends.
services.value = Array.from(rows.values()).filter(
(r) =>
!(
r.infer_service_name &&
realNames.has(r.service_name) &&
["external", "rpc", "service"].includes(classifyEntity(false, r.infer_service_type))
),
);
};
const closePanelIfRowGone = () => {
const selectedId = selectedServiceRow.value?.id;
if (selectedId && !services.value.some((r) => r.id === selectedId)) {
handleCloseSidePanel();
}
};
await fetchQueryDataWithHttpStream(
{
@@ -1047,17 +1328,17 @@ ORDER BY total_requests DESC`;
type: "search",
pageType: "traces",
searchType: "ui",
traceId: currentTraceId,
traceId,
org_id: searchObj.organizationIdentifier,
},
{
data: (_payload: any, response: any) => {
if (load !== catalogLoad) return;
if (
response.type === "search_response_hits" ||
response.type === "search_response_metadata"
) {
const hits: any[] = response.content?.results?.hits ?? [];
const serviceMap = new Map(services.value.map((s) => [s.id, s]));
for (const hit of hits) {
const name = hit.service_name ?? "";
const inferredName = hit._infer_service_name ?? undefined;
@@ -1069,7 +1350,7 @@ ORDER BY total_requests DESC`;
infer_service_system: inferredSystem,
infer_service_type: inferredType,
});
serviceMap.set(id, {
rows.set(id, {
id,
service_name: name,
total_requests: hit.total_requests ?? 0,
@@ -1086,23 +1367,29 @@ ORDER BY total_requests DESC`;
infer_service_system: inferredSystem,
infer_service_type: inferredType,
});
if (hit._is_real_service) realNames.add(name);
}
// RPC entities are kept as their own category (matching the Service
// Graph and the shared classifier) — never dropped, so a genuine
// uninstrumented gRPC backend is never hidden.
services.value = Array.from(serviceMap.values());
// Metadata arrives before any hits, so publishing it would blank the previous rows mid-refresh.
if (hits.length > 0) publish();
}
},
error: () => {
if (load !== catalogLoad) return;
closePanelIfRowGone();
isLoading.value = false;
isSearching.value = false;
},
complete: () => {
if (load !== catalogLoad) return;
// Publishing here also clears the previous search's rows when this one returned no batches.
publish();
closePanelIfRowGone();
isLoading.value = false;
isSearching.value = false;
lastRunAt.value = Date.now();
},
reset: () => {
if (load !== catalogLoad) return;
services.value = [];
isLoading.value = false;
isSearching.value = false;
@@ -1128,7 +1415,6 @@ watch(
if (newStream && newStream !== streamFilter.value) {
streamFilter.value = newStream;
localStorage.setItem("servicesCatalog_streamFilter", newStream);
hasInferColumns.value = null;
}
},
);
+99
View File
@@ -1102,3 +1102,102 @@ describe("SpanBlock marker drill-down", () => {
expect(wrapper.emitted("selectSpan")).toEqual([[mockSpan.spanId]]);
});
});
describe("SpanBlock critical path", () => {
const spanLengthUs = mockSpan.endTimeUs - mockSpan.startTimeUs;
const criticalSpan = {
...mockSpan,
criticalSections: [
{
spanId: mockSpan.spanId,
sectionStartUs: mockSpan.startTimeUs - 0.4,
sectionEndUs: mockSpan.startTimeUs + spanLengthUs / 4,
},
{
spanId: mockSpan.spanId,
sectionStartUs: mockSpan.startTimeUs + spanLengthUs / 2,
sectionEndUs: mockSpan.endTimeUs + 0.6,
},
],
};
const nonCriticalSpan = { ...mockSpan, criticalSections: [] };
let wrapper: any;
const mountBlock = async (span: any, showCriticalPath: boolean, selectedSpanId: any = null) => {
wrapper = mount(SpanBlock, {
props: {
span,
showCriticalPath,
baseTracePosition: mockBaseTracePosition,
spanDimensions: mockSpanDimensions,
spanData: mockSpanData,
},
global: { plugins: [i18n, router], provide: { store: mockStore } },
});
wrapper.vm.searchObj.data.traceDetails.selectedSpanId = selectedSpanId;
await flushPromises();
};
const isDimmed = () =>
wrapper.find('[data-test="span-block-select-trigger"]').classes().includes("opacity-30");
afterEach(() => {
wrapper.vm.searchObj.data.traceDetails.selectedSpanId = null;
wrapper.unmount();
});
it("draws one overlay per section, positioned on the bar's own time basis and clamped", async () => {
await mountBlock(criticalSpan, true);
const segments = wrapper.findAll('[data-test="span-critical-section"]');
expect(segments).toHaveLength(2);
expect(segments[0].attributes("style")).toContain("left: 0%");
expect(segments[0].attributes("style")).toContain("width: 25%");
expect(segments[1].attributes("style")).toContain("left: 50%");
expect(segments[1].attributes("style")).toContain("width: 50%");
});
it("colours the overlay with the accent token, not the error one", async () => {
await mountBlock(criticalSpan, true);
const classes = wrapper.find('[data-test="span-critical-section"]').classes();
expect(classes).toContain("bg-accent");
expect(classes).not.toContain("bg-status-error-text");
});
it("draws no overlay when the critical path toggle is off", async () => {
await mountBlock(criticalSpan, false);
expect(wrapper.findAll('[data-test="span-critical-section"]')).toHaveLength(0);
});
it("dims a span with no critical section while the toggle is on", async () => {
await mountBlock(nonCriticalSpan, true);
expect(isDimmed()).toBe(true);
});
it("does not dim a critical span while the toggle is on", async () => {
await mountBlock(criticalSpan, true);
expect(isDimmed()).toBe(false);
});
it("does not dim a non-critical span while the toggle is off", async () => {
await mountBlock(nonCriticalSpan, false);
expect(isDimmed()).toBe(false);
});
it("never dims the selected span, even when it is off the critical path", async () => {
await mountBlock(nonCriticalSpan, true, mockSpan.spanId);
expect(isDimmed()).toBe(false);
});
it("still dims a critical span when another span is selected", async () => {
await mountBlock(criticalSpan, true, "some-other-span");
expect(isDimmed()).toBe(true);
});
});
+37 -2
View File
@@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
class="wrap bg-surface-base flex items-center justify-start"
:class="defocusSpan ? 'opacity-30' : ''"
:class="dimmed ? 'opacity-30' : ''"
:style="{
zIndex: 2,
}"
@@ -53,7 +53,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
the bar itself — that would rebase them onto the span. -->
<div
class="relative flex w-full cursor-pointer flex-nowrap items-center"
:class="defocusSpan ? 'opacity-30' : ''"
:class="dimmed ? 'opacity-30' : ''"
@click="selectSpan(span.spanId)"
data-test="span-block-select-trigger"
>
@@ -73,6 +73,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
backgroundColor: span.style?.color || DEFAULT_SPAN_COLOR,
}"
/>
<div
v-for="(segment, segmentIndex) in criticalSegments"
:key="segmentIndex"
class="bg-accent rounded-default absolute top-0 h-full"
:style="{ left: segment.left + '%', width: segment.width + '%' }"
data-test="span-critical-section"
/>
</div>
<button
v-for="cluster in eventClusters"
@@ -123,6 +130,7 @@ import {
type SpanEventMarker,
type SpanEventCluster,
} from "@/composables/traces/useSpanEvents";
import type { CriticalPathSection } from "@/utils/traces/criticalPath";
// TODO(design-tokens): fallback bar colour for a span the trace colour allocator
// never assigned. No semantic token fits — it is a categorical "unassigned span"
@@ -225,6 +233,10 @@ export default defineComponent({
type: Object,
default: () => ({}),
},
showCriticalPath: {
type: Boolean,
default: false,
},
},
emits: ["toggleCollapse", "selectSpan", "hover", "view-logs", "selectSpanEvent"],
setup(props, { emit }) {
@@ -240,6 +252,27 @@ export default defineComponent({
return searchObj.data.traceDetails.selectedSpanId !== props.span.spanId;
});
const criticalSections = computed<CriticalPathSection[]>(
() => props.span?.criticalSections ?? [],
);
const dimmed = computed(() => {
if (searchObj.data.traceDetails.selectedSpanId === props.span.spanId) return false;
return defocusSpan.value || (props.showCriticalPath && !criticalSections.value.length);
});
// Positioned against the span's own endpoints, since the stored duration can differ from them by a fraction of a µs.
const criticalSegments = computed(() => {
const spanLengthUs = props.span.endTimeUs - props.span.startTimeUs;
if (!props.showCriticalPath || !(spanLengthUs > 0)) return [];
const toPercent = (us: number) =>
Math.min(100, Math.max(0, ((us - props.span.startTimeUs) / spanLengthUs) * 100));
return criticalSections.value.map((section) => {
const left = toPercent(section.sectionStartUs);
return { left, width: toPercent(section.sectionEndUs) - left };
});
});
const durationStyle = ref({});
/**
@@ -484,6 +517,8 @@ export default defineComponent({
onePixelPercent,
spanMarkerRef,
defocusSpan,
dimmed,
criticalSegments,
store,
onSpanHover,
durationStyle,
+335
View File
@@ -127,6 +127,7 @@ describe("TraceDetails", () => {
// switches tabs would otherwise leak its selection into every later test.
localStorage.removeItem("o2_trace_active_tab");
localStorage.removeItem("o2_trace_tab_order");
localStorage.removeItem("o2_trace_critical_path");
// Mock router query params
vi.spyOn(router, "currentRoute", "get").mockReturnValue({
@@ -246,6 +247,7 @@ describe("TraceDetails", () => {
"hoveredSpanId",
"isSidebarOpen",
"scrollContainer",
"showCriticalPath",
],
emits: [
"toggle-collapse",
@@ -569,6 +571,339 @@ describe("TraceDetails", () => {
// });
// });
describe("Critical path", () => {
const toggle = () => wrapper.find('[data-test="trace-details-critical-path-toggle-btn"]');
const treeShowsCriticalPath = () =>
wrapper.findComponent('[data-test="trace-details-tree"]').props("showCriticalPath");
async function openTab(tab: string) {
wrapper.vm.activeTab = tab;
await flushPromises();
}
it("shows the toggle only on the Waterfall tab", async () => {
await openTab("waterfall");
expect(toggle().exists()).toBe(true);
await openTab("flame-graph");
expect(toggle().exists()).toBe(false);
await openTab("spans");
expect(toggle().exists()).toBe(false);
});
it("is off by default and flips the waterfall overlay when toggled", async () => {
await openTab("waterfall");
expect(toggle().attributes("aria-checked")).toBe("false");
expect(treeShowsCriticalPath()).toBe(false);
await toggle().trigger("click");
await flushPromises();
expect(toggle().attributes("aria-checked")).toBe("true");
expect(treeShowsCriticalPath()).toBe(true);
});
it("persists the toggle across remounts", async () => {
await openTab("waterfall");
await toggle().trigger("click");
await flushPromises();
expect(localStorage.getItem("o2_trace_critical_path")).toBe("true");
await remount();
await openTab("waterfall");
expect(toggle().attributes("aria-checked")).toBe("true");
expect(treeShowsCriticalPath()).toBe(true);
});
it("stores each span's critical sections on its waterfall row", () => {
const rows = wrapper.vm.spanPositionList;
expect(rows.length).toBe(tracesMockData.tracesDetails.traceSpans.hits.length);
rows.forEach((row: any) => {
expect(row.criticalSections.length).toBeGreaterThan(0);
row.criticalSections.forEach((section: any) => expect(section.spanId).toBe(row.spanId));
});
// The deepest span of the mock's single chain is critical for its whole length.
const leaf = rows.find((row: any) => !row.spans.length);
expect(leaf.criticalSections).toEqual([
{ spanId: leaf.spanId, sectionStartUs: leaf.startTimeUs, sectionEndUs: leaf.endTimeUs },
]);
});
it("gives an orphan root no critical path even when it starts before the true root", async () => {
const template = tracesMockData.tracesDetails.traceSpans.hits[0];
const base = template.start_time;
const span = (
spanId: string,
parentId: string,
startOffsetNs: number,
endOffsetNs: number,
) => ({
...template,
span_id: spanId,
reference_parent_span_id: parentId,
start_time: base + startOffsetNs,
end_time: base + endOffsetNs,
_start_time_ns: String(base + startOffsetNs),
_end_time_ns: String(base + endOffsetNs),
});
localStorage.setItem("o2_trace_critical_path", "true");
wrapper.unmount();
wrapper = mount(TraceDetails, {
...mountOptions,
props: {
mode: "embedded",
traceIdProp: "orphan-trace-id",
streamNameProp: "test-stream",
spanListProp: [
span("orphan", "parent-missing-from-trace", 0, 3_000_000),
span("true-root", "", 1_000_000, 5_000_000),
span("root-child", "true-root", 2_000_000, 4_000_000),
],
},
});
await flushPromises();
expect(treeShowsCriticalPath()).toBe(true);
const row = (spanId: string) =>
wrapper.vm.spanPositionList.find((r: any) => r.spanId === spanId);
expect(row("orphan").criticalSections).toEqual([]);
expect(row("true-root").criticalSections.length).toBeGreaterThan(0);
});
});
describe("Trace Graph view toggle and service search", () => {
const template = tracesMockData.tracesDetails.traceSpans.hits[0];
const spans = [
{ ...template, span_id: "s1", reference_parent_span_id: "", service_name: "frontend" },
{ ...template, span_id: "s2", reference_parent_span_id: "s1", service_name: "checkout" },
{ ...template, span_id: "s3", reference_parent_span_id: "s2", service_name: "payments" },
{ ...template, span_id: "s4", reference_parent_span_id: "s1", service_name: "catalog" },
];
const treeBtn = () => wrapper.find('[data-test="trace-graph-tree-view-btn"]');
const graphBtn = () => wrapper.find('[data-test="trace-graph-graph-view-btn"]');
const series = () =>
wrapper.findComponent('[data-test="trace-details-service-map-chart"]').props("data").options
.series?.[0];
const chartData = () =>
wrapper.findComponent('[data-test="trace-details-service-map-chart"]').props("data");
const treeNames = (nodes: any[] = []): string[] =>
nodes.flatMap((n) => [n.name, ...treeNames(n.children)]);
let fakeChart: any;
async function mountMap() {
wrapper.unmount();
wrapper = mount(TraceDetails, {
...mountOptions,
global: {
...mountOptions.global,
stubs: {
...mountOptions.global.stubs,
"chart-renderer": {
...mountOptions.global.stubs["chart-renderer"],
data: () => ({ chart: fakeChart }),
},
},
},
props: {
mode: "embedded",
traceIdProp: "graph-trace-id",
streamNameProp: "test-stream",
spanListProp: spans,
},
});
await flushPromises();
wrapper.vm.activeTab = "map";
await flushPromises();
}
beforeEach(() => {
localStorage.removeItem("o2_trace_graph_view");
fakeChart = { getDom: () => document.createElement("div"), on: vi.fn(), off: vi.fn() };
});
it("renders the toggle and service search only on the Trace Graph tab", async () => {
await mountMap();
expect(treeBtn().exists()).toBe(true);
expect(graphBtn().exists()).toBe(true);
expect(wrapper.find('[data-test="trace-graph-search-input"]').exists()).toBe(true);
wrapper.vm.activeTab = "waterfall";
await flushPromises();
expect(treeBtn().exists()).toBe(false);
expect(wrapper.find('[data-test="trace-graph-search-input"]').exists()).toBe(false);
});
it("defaults to Tree View and switches to a graph series", async () => {
await mountMap();
expect(series().type).toBe("tree");
await graphBtn().trigger("click");
await flushPromises();
expect(wrapper.vm.traceGraphView).toBe("graph");
expect(series().type).toBe("graph");
expect(
series()
.data.map((n: any) => n.name)
.sort(),
).toEqual(["catalog", "checkout", "frontend", "payments"]);
expect(
series()
.links.map((l: any) => [l.source, l.target])
.sort(),
).toEqual([
["checkout", "payments"],
["frontend", "catalog"],
["frontend", "checkout"],
]);
expect(
wrapper.findComponent('[data-test="trace-details-service-map-chart"]').props("data")
.notMerge,
).toBe(true);
});
it("persists the chosen view across remounts", async () => {
await mountMap();
await graphBtn().trigger("click");
await flushPromises();
expect(localStorage.getItem("o2_trace_graph_view")).toBe("graph");
await mountMap();
expect(series().type).toBe("graph");
});
it("filters the tree to matches and their ancestors from the search input", async () => {
await mountMap();
const field = wrapper.find('[data-test="trace-graph-search-input"]');
const input = field.element.tagName === "INPUT" ? field : field.find("input");
await input.setValue("PAY");
await new Promise((resolve) => setTimeout(resolve, 350));
await flushPromises();
expect(treeNames(series().data)).toEqual(["frontend", "checkout", "payments"]);
});
it("filters the graph to edges touching a match", async () => {
await mountMap();
await graphBtn().trigger("click");
wrapper.vm.traceGraphSearch = "pay";
await flushPromises();
expect(
series()
.data.map((n: any) => n.name)
.sort(),
).toEqual(["checkout", "payments"]);
});
it("renders an empty chart when no service matches", async () => {
await mountMap();
wrapper.vm.traceGraphSearch = "nope";
await flushPromises();
expect(chartData()).toEqual({ options: {}, notMerge: true });
wrapper.vm.traceGraphSearch = "";
await flushPromises();
expect(series().type).toBe("tree");
await graphBtn().trigger("click");
wrapper.vm.traceGraphSearch = "nope";
await flushPromises();
expect(chartData()).toEqual({ options: {}, notMerge: true });
});
it("insets the graph series so nodes and their labels stay inside the chart", async () => {
await mountMap();
await graphBtn().trigger("click");
await flushPromises();
expect(series()).toMatchObject({ top: 55, bottom: 79, left: 64, right: 175 });
});
it("caps the graph label insets in a narrow chart", async () => {
await mountMap();
const el = wrapper.find('[data-test="trace-details-service-map-chart"]').element;
Object.defineProperty(el, "clientWidth", { configurable: true, value: 400 });
await graphBtn().trigger("click");
await flushPromises();
expect(series()).toMatchObject({ left: 64, right: 100 });
Object.defineProperty(el, "clientWidth", { configurable: true, value: 200 });
wrapper.vm.traceGraphSearch = " ";
await flushPromises();
expect(series()).toMatchObject({ left: 55, right: 55 });
});
it("truncates graph labels and hides overlapping ones", async () => {
await mountMap();
await graphBtn().trigger("click");
await flushPromises();
expect(series().label).toMatchObject({ show: true, width: 120, overflow: "truncate" });
expect(series().labelLayout).toEqual({ hideOverlap: true });
series().data.forEach((n: any) => expect(n.tooltip.formatter).toContain(n.name));
});
it("lays out a graph with finite positions in a tiny chart container", async () => {
await mountMap();
const el = wrapper.find('[data-test="trace-details-service-map-chart"]').element;
Object.defineProperty(el, "clientWidth", { configurable: true, value: 1000 });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 100 });
await graphBtn().trigger("click");
await flushPromises();
expect(series().data.length).toBe(4);
series().data.forEach((n: any) => {
expect(Number.isFinite(n.x)).toBe(true);
expect(Number.isFinite(n.y)).toBe(true);
});
});
it("skips the custom tree tooltip in Graph View and restores it in Tree View", async () => {
const tooltipSetupDelay = () => new Promise((resolve) => setTimeout(resolve, 350));
await mountMap();
await graphBtn().trigger("click");
await tooltipSetupDelay();
expect(fakeChart.on).not.toHaveBeenCalled();
await treeBtn().trigger("click");
await tooltipSetupDelay();
expect(fakeChart.on).toHaveBeenCalledWith("mouseover", expect.any(Function));
});
it("detaches the tree tooltip when switching from Tree View to Graph View", async () => {
const tooltipSetupDelay = () => new Promise((resolve) => setTimeout(resolve, 350));
await mountMap();
await graphBtn().trigger("click");
await treeBtn().trigger("click");
await tooltipSetupDelay();
expect(fakeChart.on).toHaveBeenCalledWith("mouseover", expect.any(Function));
expect(fakeChart.off).not.toHaveBeenCalled();
await graphBtn().trigger("click");
await flushPromises();
expect(fakeChart.off).toHaveBeenCalledWith("mouseover", expect.any(Function));
});
it("does not attach the tree tooltip when Graph View is chosen during the setup delay", async () => {
const tooltipSetupDelay = () => new Promise((resolve) => setTimeout(resolve, 350));
await mountMap();
await graphBtn().trigger("click");
await tooltipSetupDelay();
// Both calls land before the first one's nextTick resumes and schedules its timer.
wrapper.vm.setTraceGraphView("tree");
wrapper.vm.setTraceGraphView("graph");
await tooltipSetupDelay();
expect(wrapper.vm.traceGraphView).toBe("graph");
expect(fakeChart.on).not.toHaveBeenCalled();
});
});
describe("Data processing", () => {
it("should process span data correctly", () => {
expect(wrapper.vm.spanList).toEqual(tracesMockData.tracesDetails.traceSpans.hits);
+165 -2
View File
@@ -441,6 +441,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div class="flex items-center gap-2 space-x-2 pe-[0.325rem]">
<OSwitch
v-if="activeTab === 'waterfall'"
:model-value="showCriticalPath"
:label="t('traces.criticalPath')"
size="sm"
data-test="trace-details-critical-path-toggle"
@update:model-value="setShowCriticalPath"
/>
<!-- Unified Search Input Group -->
<div
v-if="activeTab !== 'flame-graph' && activeTab !== 'map' && activeTab !== 'thread'"
@@ -612,6 +620,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:selectedSpanId="selectedSpanId"
:hoveredSpanId="hoveredSpanId"
:isSidebarOpen="!!(isSidebarOpen && (selectedSpanId || showTraceDetails))"
:showCriticalPath="showCriticalPath"
@toggle-collapse="toggleSpanCollapse"
@select-span="updateSelectedSpan"
@select-span-event="onSelectSpanEvent"
@@ -819,6 +828,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Map View with Pattern/Span Toggle -->
<div v-if="activeTab === 'map'" class="flex h-full min-h-0 w-full flex-1 flex-col">
<div class="flex items-center gap-2 px-2.5 pt-2.5">
<OToggleGroup :model-value="traceGraphView" @update:model-value="setTraceGraphView">
<OToggleGroupItem data-test="trace-graph-tree-view-btn" value="tree" size="sm">
<template #icon-left>
<OIcon name="git-branch" size="sm" />
</template>
<span class="max-lg:hidden">{{ t("traces.treeView") }}</span>
</OToggleGroupItem>
<OToggleGroupItem data-test="trace-graph-graph-view-btn" value="graph" size="sm">
<template #icon-left
><OIcon name="share" size="sm" class="shrink-0"
/></template>
<span class="max-lg:hidden">{{ t("traces.graphView") }}</span>
</OToggleGroupItem>
</OToggleGroup>
<OSearchInput
v-model="traceGraphSearch"
data-test="trace-graph-search-input"
class="w-56! max-lg:w-40!"
:placeholder="t('traces.serviceGraph.searchPlaceholder')"
:debounce="300"
clearable
/>
</div>
<!-- Chart Container -->
<div class="min-h-0 flex-1 overflow-hidden p-2.5">
<ChartRenderer
@@ -926,7 +959,17 @@ import {
} from "@/utils/zincutils";
import TraceTimelineIcon from "@/components/icons/TraceTimelineIcon.vue";
import ServiceMapIcon from "@/components/icons/ServiceMapIcon.vue";
import { convertTimelineData, convertTraceServiceMapData } from "@/utils/traces/convertTraceData";
import {
MAX_NETWORK_NODE_SYMBOL_SIZE,
convertServiceGraphToNetwork,
convertTimelineData,
convertTraceServiceMapData,
} from "@/utils/traces/convertTraceData";
import {
buildTraceServiceGraph,
filterTraceServiceGraph,
filterTraceTree,
} from "@/utils/traces/traceServiceGraph";
import { getAllSpanColors } from "@/utils/traces/traceColors";
import { resolveReplaySpan, resolveSessionId, resolveUrlTimeRange } from "./traceDetails.utils";
import { buildFilterTerm, applyFilterTerm } from "@/utils/traces/filterUtils";
@@ -957,6 +1000,11 @@ import { escapeSingleQuotes } from "@/utils/queryUtils";
import useNotifications from "@/composables/useNotifications";
import { parseUsageDetails, parseCostDetails, hasTracePreview, isLLMTrace } from "@/utils/llmUtils";
import { formatTimestamp, useTraceProcessing } from "@/composables/traces/useTraceProcessing";
import {
computeCriticalPathForRoots,
toCriticalPathNode,
type CriticalPathSection,
} from "@/utils/traces/criticalPath";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OButton from "@/lib/core/Button/OButton.vue";
@@ -966,6 +1014,7 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
@@ -1038,6 +1087,10 @@ const DEFAULT_TRACE_TAB: TraceTabValue = "waterfall";
const LS_TRACE_TAB_ORDER_KEY = "o2_trace_tab_order";
const LS_TRACE_ACTIVE_TAB_KEY = "o2_trace_active_tab";
const LS_TRACE_CRITICAL_PATH_KEY = "o2_trace_critical_path";
const LS_TRACE_GRAPH_VIEW_KEY = "o2_trace_graph_view";
type TraceGraphView = "tree" | "graph";
const isKnownTraceTab = (value: string): value is TraceTabValue =>
TRACE_TAB_DEFS.some((tab) => tab.value === value);
@@ -1082,6 +1135,22 @@ function loadTraceActiveTab(): TraceTabValue {
return DEFAULT_TRACE_TAB;
}
function loadTraceCriticalPath(): boolean {
try {
return localStorage.getItem(LS_TRACE_CRITICAL_PATH_KEY) === "true";
} catch {
return false;
}
}
function loadTraceGraphView(): TraceGraphView {
try {
return localStorage.getItem(LS_TRACE_GRAPH_VIEW_KEY) === "graph" ? "graph" : "tree";
} catch {
return "tree";
}
}
export default defineComponent({
name: "TraceDetails",
props: {
@@ -1180,6 +1249,7 @@ export default defineComponent({
OTooltip,
OSearchInput,
OSelect,
OSwitch,
ManualEvaluationDialog,
TraceAnnotateMenu,
AddToDatasetDrawer,
@@ -1195,6 +1265,9 @@ export default defineComponent({
const spanMap: any = ref({});
const activeTab = ref<string>(loadTraceActiveTab());
const tabOrder = ref<TraceTabValue[]>(loadTraceTabOrder());
const showCriticalPath = ref(loadTraceCriticalPath());
const traceGraphView = ref<TraceGraphView>(loadTraceGraphView());
const traceGraphSearch = ref("");
const sidebarActiveTab = ref("attributes");
const { searchObj, getUrlQueryParams, navigateToCorrelatedLogs } = useTraces();
@@ -1247,6 +1320,51 @@ export default defineComponent({
// Computed chart options that switches between pattern and span views
const traceServiceMapChartOptions = computed(() => {
if (traceGraphView.value === "graph") {
const graph = filterTraceServiceGraph(
buildTraceServiceGraph(effectiveSpanList.value, t("traces.traceDetails.unknownService")),
traceGraphSearch.value,
);
if (!graph.nodes.length) return { options: {}, notMerge: true };
const chartWidth = chartRendererRef.value?.$el?.clientWidth || 1200;
const network = convertServiceGraphToNetwork(
graph,
"force",
new Map(),
isDarkMode.value,
undefined,
// computeForceLayout subtracts ~330×310 of padding; smaller sizes yield NaN positions.
Math.max(chartWidth, 800),
Math.max(chartRendererRef.value?.$el?.clientHeight || 700, 500),
);
// ECharts fits node centres, not symbols, into the series box; inset by the largest radius.
const inset = MAX_NETWORK_NODE_SYMBOL_SIZE / 2;
const labelWidth = 120;
// Label reserves are capped at a quarter of the width so narrow charts keep room for nodes.
const labelReserve = (wanted: number) => Math.max(inset, Math.min(wanted, chartWidth / 4));
const [graphSeries, ...otherSeries] = network.options.series;
return {
options: {
...network.options,
series: [
{
...graphSeries,
top: inset,
// A bottom label is centred on its node, so half its width plus the text stroke overhangs left.
left: labelReserve(labelWidth / 2 + 4),
// Edge nodes still get labels below or to their right, so reserve a text line and a label width.
bottom: inset + 24,
right: labelReserve(inset + labelWidth),
label: { ...graphSeries.label, width: labelWidth, overflow: "truncate" },
labelLayout: { hideOverlap: true },
},
...otherSeries,
],
},
notMerge: true,
lazyUpdate: true,
};
}
// Pattern view - use new pattern-based visualization
// Engine TreeNode makes errorRate/children optional while the pattern
// callbacks (useTreeVisualization) require errorRate; adapt each call to
@@ -1260,9 +1378,12 @@ export default defineComponent({
errorRate: node.errorRate ?? 0,
metadata: node.metadata,
});
const treeData = filterTraceTree(patternTreeData.value, traceGraphSearch.value);
// ECharts' tree series throws on empty data and leaves the chart stuck until reload.
if (!treeData.length) return { options: {}, notMerge: true };
const chartOptions = generateEChartsOptions(
{
treeData: patternTreeData.value,
treeData,
getNodeLabel: (node: EngineTreeNode) => getPatternNodeLabel(toPatternNode(node)),
getNodeTooltip: (node: EngineTreeNode) => getPatternNodeTooltip(toPatternNode(node)),
getNodeErrorRate: (node: EngineTreeNode) => getPatternNodeErrorRate(toPatternNode(node)),
@@ -1910,6 +2031,25 @@ export default defineComponent({
},
);
const setShowCriticalPath = (value: unknown) => {
showCriticalPath.value = value === true;
try {
localStorage.setItem(LS_TRACE_CRITICAL_PATH_KEY, String(showCriticalPath.value));
} catch {
// Storage unavailable — the toggle still applies for this session.
}
};
const setTraceGraphView = (value: boolean | AcceptableValue | AcceptableValue[]) => {
traceGraphView.value = value === "graph" ? "graph" : "tree";
try {
localStorage.setItem(LS_TRACE_GRAPH_VIEW_KEY, traceGraphView.value);
} catch {
// Storage unavailable — the view still applies for this session.
}
setupTooltips();
};
const updateActiveTab = (value: boolean | AcceptableValue | AcceptableValue[]) => {
const tab = String(value);
activeTab.value = tab;
@@ -1935,11 +2075,15 @@ export default defineComponent({
clearTimeout(pendingTooltipSetup);
pendingTooltipSetup = null;
}
// Graph View draws ECharts' own tooltips; the custom tree tooltip would overlay them.
if (traceGraphView.value !== "tree") return;
await nextTick();
// 300ms delay matches Service Graph tooltip setup timing
pendingTooltipSetup = setTimeout(() => {
pendingTooltipSetup = null;
// The view or tab may have changed during the delay.
if (traceGraphView.value !== "tree" || activeTab.value !== "map") return;
const chart = chartRendererRef.value?.chart;
if (chart) {
const { setupTraceNodeTooltips } = createTreeVisualizationEngine();
@@ -2400,6 +2544,8 @@ export default defineComponent({
traceTree.value[0].highestEndTime = convertTimeFromNsToUs(highestEndTime);
traceTree.value[0].style.color = getOrSetServiceColor(traceTree.value[0].resolvedIdentity);
assignCriticalSections(Object.values(formattedSpanMap));
traceTree.value.forEach((span: any) => {
addSpansPositions(span, 0);
});
@@ -2612,6 +2758,18 @@ export default defineComponent({
};
};
const assignCriticalSections = (spans: any[]) => {
const sectionsBySpan = new Map<string, CriticalPathSection[]>();
computeCriticalPathForRoots(traceTree.value.map(toCriticalPathNode)).forEach((section) => {
const sections = sectionsBySpan.get(section.spanId) ?? [];
sections.push(section);
sectionsBySpan.set(section.spanId, sections);
});
spans.forEach((span) => {
span.criticalSections = sectionsBySpan.get(span.spanId) ?? [];
});
};
const convertTime = (time: number) => {
return Number((time / 1000).toFixed(2));
};
@@ -3016,6 +3174,11 @@ export default defineComponent({
router,
t,
raw,
showCriticalPath,
setShowCriticalPath,
traceGraphView,
setTraceGraphView,
traceGraphSearch,
// Exposed for the template `v-if` gating the LLM Observability
// surfaces (Thread tab toggle + ThreadView body) behind
// `config.showLLMUI`.
@@ -25,15 +25,34 @@ const {
mockNavigateToCorrelatedLogs,
mockToast,
mockPromptResolve,
mockSearch,
mockSearchObj,
mockStreamNameList,
mockStreamSchema,
} = vi.hoisted(() => ({
mockStreamNameList: vi.fn().mockResolvedValue({ data: { list: [] } }),
mockStreamSchema: vi.fn().mockResolvedValue({ data: { schema: [] } }),
mockLoadSemanticGroups: vi.fn().mockResolvedValue([]),
mockBuildQueryDetails: vi.fn().mockReturnValue({}),
mockNavigateToLogs: vi.fn(),
mockNavigateToCorrelatedLogs: vi.fn(),
mockPromptResolve: vi.fn(),
mockToast: vi.fn(),
mockSearch: vi.fn().mockResolvedValue({ data: { hits: [] } }),
mockSearchObj: { meta: { serviceColors: { scheduler: "#1ab8be" } } } as Record<string, any>,
}));
vi.mock("@/services/search", () => ({
default: { search: mockSearch },
}));
vi.mock("@/services/stream", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/services/stream")>();
return {
default: { ...actual.default, nameList: mockStreamNameList, schema: mockStreamSchema },
};
});
vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: mockToast,
}));
@@ -48,7 +67,7 @@ vi.mock("@/utils/traces/convertTraceData", () => ({
vi.mock("@/composables/useTraces", () => ({
default: () => ({
searchObj: { meta: { serviceColors: { scheduler: "#1ab8be" } } },
searchObj: mockSearchObj,
buildQueryDetails: mockBuildQueryDetails,
navigateToLogs: mockNavigateToLogs,
navigateToCorrelatedLogs: mockNavigateToCorrelatedLogs,
@@ -79,10 +98,12 @@ vi.mock("@/composables/traces/useTraceDetails", () => ({
import componentSource from "@/plugins/traces/TraceDetailsSidebar.vue?raw";
import { getServiceIconDataUrl } from "@/utils/traces/convertTraceData";
import TraceDetailsSidebar from "@/plugins/traces/TraceDetailsSidebar.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import useTraceDetails from "@/composables/traces/useTraceDetails";
import config from "@/aws-exports";
import i18n from "@/locales";
import router from "@/test/unit/helpers/router";
import { queryClient } from "@/composables/query/queryClient";
import { createStore } from "vuex";
// CSS.supports is not available in jsdom but used by TenstackTable
@@ -2237,3 +2258,426 @@ describe("TraceDetailsSidebar", async () => {
});
});
});
describe("TraceDetailsSidebar — span vs operation percentiles", () => {
const SAVED_START = 1752490000000000;
const SAVED_END = 1752490900000000;
const SPAN_START_US = Math.floor(mockSpan.start_time / 1_000);
const HOUR_US = 3_600_000_000;
const BUCKET_US = 600_000_000;
const fallbackWindow = (spanStartUs: number) => {
const bucketUs = Math.floor(spanStartUs / BUCKET_US) * BUCKET_US;
return [bucketUs - HOUR_US, bucketUs + HOUR_US + BUCKET_US];
};
const STATS = { n: 120, p50: 1000, p75: 2000, p90: 4000, p99: 8000 };
const PERCENTILE_TAG_ID = "trace-details-sidebar-header-toolbar-operation-percentile";
const PERCENTILE_TAG = `[data-test="${PERCENTILE_TAG_ID}"]`;
function statsResponse(overrides: Record<string, number> = {}) {
return { data: { hits: [{ ...STATS, ...overrides }] } };
}
async function mountWith(spanOverrides: Record<string, unknown> = {}, props = {}) {
const w = mountSidebar({
span: { ...mockSpan, ...spanOverrides },
streamName: "default",
...props,
});
await flushPromises();
return w;
}
function percentileTag(w: ReturnType<typeof mountSidebar>) {
return w.findAllComponents(OTag).find((c) => c.find(PERCENTILE_TAG).exists());
}
beforeEach(() => {
mockSearch.mockReset();
mockSearch.mockResolvedValue(statsResponse());
mockSearchObj.data = {
stream: { selectedStream: { label: "default", value: "default" } },
queryPayload: { query: { start_time: SAVED_START, end_time: SAVED_END } },
traceDetails: {},
};
});
afterEach(() => {
delete (mockSearchObj as Record<string, unknown>).data;
});
it("queries the four percentiles with quoted service, operation and stream", async () => {
const w = await mountWith({
_stream: 'odd"stream',
service_name: "o'svc",
operation_name: "GET /it's",
});
expect(mockSearch).toHaveBeenCalledTimes(1);
const arg = mockSearch.mock.calls[0][0];
const sql: string = arg.query.query.sql;
expect(arg.page_type).toBe("traces");
expect(arg.org_identifier).toBe("test-org");
expect(sql).toContain('FROM "odd""stream"');
expect(sql).toContain("service_name = 'o''svc'");
expect(sql).toContain("operation_name = 'GET /it''s'");
expect(sql).toContain("COUNT(*) AS n");
for (const [q, alias] of [
["0.5", "p50"],
["0.75", "p75"],
["0.9", "p90"],
["0.99", "p99"],
]) {
expect(sql).toContain(`approx_percentile_cont(duration, ${q}) AS ${alias}`);
}
w.unmount();
});
it("uses the page's saved query bounds when the span's stream is the page stream", async () => {
const w = await mountWith();
const q = mockSearch.mock.calls[0][0].query.query;
expect([q.start_time, q.end_time]).toEqual([SAVED_START, SAVED_END]);
w.unmount();
});
it("falls back to the span's 10-minute bucket ± 1 h for another stream", async () => {
const w = await mountWith({ _stream: "other" });
const q = mockSearch.mock.calls[0][0].query.query;
expect([q.start_time, q.end_time]).toEqual(fallbackWindow(SPAN_START_US));
w.unmount();
});
it("falls back to the span's 10-minute bucket ± 1 h in embedded mode", async () => {
const w = await mountWith({}, { parentMode: "embedded" });
const q = mockSearch.mock.calls[0][0].query.query;
expect([q.start_time, q.end_time]).toEqual(fallbackWindow(SPAN_START_US));
w.unmount();
});
it("falls back to the span's 10-minute bucket ± 1 h when the page has no saved bounds", async () => {
mockSearchObj.data.queryPayload = {};
const w = await mountWith();
const q = mockSearch.mock.calls[0][0].query.query;
expect([q.start_time, q.end_time]).toEqual(fallbackWindow(SPAN_START_US));
w.unmount();
});
it("shares one call between sibling spans in the same 10-minute bucket", async () => {
const bucketUs = Math.floor(SPAN_START_US / BUCKET_US) * BUCKET_US;
const w = await mountWith({ _stream: "other", start_time: (bucketUs + 1_000_000) * 1_000 });
await w.setProps({
span: {
...mockSpan,
_stream: "other",
span_id: "sibling",
start_time: (bucketUs + 120_000_000) * 1_000,
},
});
await flushPromises();
expect(mockSearch).toHaveBeenCalledTimes(1);
const q = mockSearch.mock.calls[0][0].query.query;
expect([q.start_time, q.end_time]).toEqual([
bucketUs - HOUR_US,
bucketUs + HOUR_US + BUCKET_US,
]);
w.unmount();
});
it("covers a span two hours into a long trace", async () => {
const spanStartUs = SPAN_START_US + 2 * HOUR_US;
const w = await mountWith(
{ _stream: "other", start_time: spanStartUs * 1_000 },
{ baseTracePosition: { ...mockBaseTracePosition, startTimeUs: SPAN_START_US } },
);
const q = mockSearch.mock.calls[0][0].query.query;
expect(q.start_time).toBeLessThanOrEqual(spanStartUs);
expect(q.end_time).toBeGreaterThan(spanStartUs);
expect([q.start_time, q.end_time]).toEqual(fallbackWindow(spanStartUs));
w.unmount();
});
it.each([
[500, "operationBandBelowP50", "< p50", "default-soft"],
[1000, "operationBandP50ToP75", "p50–p75", "default-soft"],
[2000, "operationBandP75ToP90", "p75–p90", "default-soft"],
[4000, "operationBandP90ToP99", "p90–p99", "warning-soft"],
[8000, "operationBandP90ToP99", "p90–p99", "warning-soft"],
[8001, "operationBandAboveP99", "> p99", "error-soft"],
])("a %sµs span reads %s (%s) with the %s variant", async (duration, bandKey, band, variant) => {
expect(i18n.global.t(`traces.traceDetailsSidebar.${bandKey}`)).toBe(band);
const w = await mountWith({ duration });
const tag = percentileTag(w);
expect(tag).toBeTruthy();
expect(tag!.text()).toBe(`${band} of ${mockSpan.operation_name}`);
expect(tag!.props("variant")).toBe(variant);
w.unmount();
});
it("puts p50, p75, p90, p99 and a labelled sample count on one tooltip line", async () => {
const w = await mountWith();
const tooltip = w
.findAllComponents({ name: "OTooltip" })
.find((c) => String(c.props("content")).includes("p99"));
expect(tooltip).toBeTruthy();
const content = String(tooltip!.props("content"));
for (const part of ["p50 1.00ms", "p75 2.00ms", "p90 4.00ms", "p99 8.00ms", "120 samples"]) {
expect(content).toContain(part);
}
expect(content).not.toContain("n 120");
expect(tooltip!.props("contentClass")).toContain("whitespace-nowrap");
expect(tooltip!.props("maxWidth")).toBe("none");
w.unmount();
});
it("formats a large sample count with thousands separators", async () => {
mockSearch.mockResolvedValue(statsResponse({ n: 45989 }));
const w = await mountWith();
const tooltip = w
.findAllComponents({ name: "OTooltip" })
.find((c) => String(c.props("content")).includes("p99"));
expect(String(tooltip!.props("content"))).toContain("45,989 samples");
w.unmount();
});
it.each([
["n < 20", () => mockSearch.mockResolvedValue(statsResponse({ n: 19 }))],
["p50 == 0", () => mockSearch.mockResolvedValue(statsResponse({ p50: 0 }))],
["the search fails", () => mockSearch.mockRejectedValue(new Error("boom"))],
["the search is still loading", () => mockSearch.mockReturnValue(new Promise(() => {}))],
])("hides the tag when %s", async (_label, arrange) => {
arrange();
const w = await mountWith();
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(w.find(PERCENTILE_TAG).exists()).toBe(false);
w.unmount();
});
it("serves sibling spans from one cached call, each with its own band", async () => {
const w = await mountWith({ duration: 500 });
expect(percentileTag(w)!.text()).toContain(
i18n.global.t("traces.traceDetailsSidebar.operationBandBelowP50"),
);
await w.setProps({ span: { ...mockSpan, span_id: "sibling", duration: 9000 } });
await flushPromises();
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(percentileTag(w)!.text()).toContain(
i18n.global.t("traces.traceDetailsSidebar.operationBandAboveP99"),
);
w.unmount();
});
});
describe("TraceDetailsSidebar — span to profile link", () => {
const SPAN = {
...mockSpan,
start_time: 1752490492000000000,
end_time: 1752490493000000000,
};
const WINDOW_START = 1752490492000000 - 60_000_000;
const WINDOW_END = 1752490493000000 + 60_000_000;
const BUTTON = '[data-test="trace-details-sidebar-header-toolbar-view-profile-btn"]';
const FULL_SCHEMA = ["trace_id", "span_id", "service_name", "profile_type"];
let profileHits: Record<string, unknown[] | Error>;
let schemas: Record<string, string[]>;
const setStreams = (streams: Record<string, string[]>) => {
schemas = streams;
mockStreamNameList.mockResolvedValue({
data: { list: Object.keys(streams).map((name) => ({ name })) },
});
};
const profileCalls = () =>
mockSearch.mock.calls.filter(([arg]) => arg.page_type === "profiles").map(([arg]) => arg);
async function mountWith(spanOverrides: Record<string, unknown> = {}, props = {}) {
const w = mountSidebar({ span: { ...SPAN, ...spanOverrides }, ...props });
await flushPromises();
return w;
}
beforeEach(() => {
queryClient.clear();
mockStreamNameList.mockClear();
profileHits = {};
setStreams({});
mockStreamSchema.mockImplementation(async (_org: string, stream: string) => ({
data: { schema: (schemas[stream] ?? []).map((name) => ({ name })) },
}));
mockSearch.mockReset();
mockSearch.mockImplementation(async (arg: any) => {
if (arg.page_type !== "profiles") return { data: { hits: [] } };
const stream = /FROM "([^"]+)"/.exec(arg.query.query.sql)?.[1] ?? "";
const hits = profileHits[stream] ?? [];
if (hits instanceof Error) throw hits;
return { data: { hits } };
});
});
afterEach(() => {
queryClient.clear();
mockStreamNameList.mockResolvedValue({ data: { list: [] } });
});
it("lists profile streams and queries the span's ids over its window ± 60 s", async () => {
setStreams({ prof: FULL_SCHEMA });
const w = await mountWith();
expect(mockStreamNameList).toHaveBeenCalledWith("test-org", "profiles", false);
const calls = profileCalls();
expect(calls).toHaveLength(1);
expect(calls[0].org_identifier).toBe("test-org");
expect(calls[0].query.query).toMatchObject({ start_time: WINDOW_START, end_time: WINDOW_END });
expect(calls[0].query.query.sql).toBe(
"SELECT profile_type, profile_unit, service_name, COUNT(*) AS c " +
`FROM "prof" WHERE trace_id = '${SPAN.trace_id}' AND span_id = '${SPAN.span_id}' ` +
"GROUP BY profile_type, profile_unit, service_name",
);
w.unmount();
});
it("selects service_name only when the stream schema has it", async () => {
setStreams({ prof: ["trace_id", "span_id"] });
const w = await mountWith();
const sql: string = profileCalls()[0].query.query.sql;
expect(sql).not.toContain("service_name");
expect(sql).toContain("GROUP BY profile_type, profile_unit");
w.unmount();
});
it("skips streams whose schema lacks trace_id or span_id without querying them", async () => {
setStreams({ noTrace: ["span_id"], noSpan: ["trace_id"], prof: FULL_SCHEMA });
const w = await mountWith();
const calls = profileCalls();
expect(calls).toHaveLength(1);
expect(calls[0].query.query.sql).toContain('FROM "prof"');
w.unmount();
});
it("shows View profile only when a stream has matching samples", async () => {
setStreams({ prof: FULL_SCHEMA });
profileHits.prof = [
{ profile_type: "cpu", profile_unit: "nanoseconds", service_name: "api", c: 3 },
];
const w = await mountWith();
expect(w.find(BUTTON).exists()).toBe(true);
expect(w.find(BUTTON).text()).toBe("View profile");
w.unmount();
});
it.each([
["no stream has rows", () => {}],
[
"the search returns 403",
() =>
(profileHits.prof = Object.assign(new Error("forbidden"), { response: { status: 403 } })),
],
["listing the streams fails", () => mockStreamNameList.mockRejectedValue(new Error("boom"))],
])("hides View profile when %s", async (_label, arrange) => {
setStreams({ prof: FULL_SCHEMA });
arrange();
const w = await mountWith();
expect(w.find(BUTTON).exists()).toBe(false);
w.unmount();
});
it("neither detects nor shows View profile outside standalone mode", async () => {
setStreams({ prof: FULL_SCHEMA });
profileHits.prof = [{ profile_type: "cpu", profile_unit: "nanoseconds", c: 1 }];
const w = await mountWith({}, { parentMode: "embedded" });
expect(profileCalls()).toHaveLength(0);
expect(w.find(BUTTON).exists()).toBe(false);
w.unmount();
});
it("runs at most three profile queries at a time", async () => {
setStreams({ a: FULL_SCHEMA, b: FULL_SCHEMA, c: FULL_SCHEMA, d: FULL_SCHEMA, e: FULL_SCHEMA });
const pending: Array<() => void> = [];
mockSearch.mockImplementation((arg: any) => {
if (arg.page_type !== "profiles") return Promise.resolve({ data: { hits: [] } });
return new Promise((resolve) => pending.push(() => resolve({ data: { hits: [] } })));
});
const w = await mountWith();
expect(profileCalls()).toHaveLength(3);
pending.splice(0).forEach((resolve) => resolve());
await flushPromises();
expect(profileCalls()).toHaveLength(5);
w.unmount();
});
it("stops probing for a span once another span is selected", async () => {
setStreams({ a: FULL_SCHEMA, b: FULL_SCHEMA, c: FULL_SCHEMA, d: FULL_SCHEMA, e: FULL_SCHEMA });
const pending: Array<() => void> = [];
mockSearch.mockImplementation((arg: any) => {
if (arg.page_type !== "profiles") return Promise.resolve({ data: { hits: [] } });
return new Promise((resolve) => pending.push(() => resolve({ data: { hits: [] } })));
});
const callsFor = (spanId: string) =>
profileCalls().filter((c) => c.query.query.sql.includes(`span_id = '${spanId}'`));
const w = await mountWith();
expect(callsFor(SPAN.span_id)).toHaveLength(3);
await w.setProps({ span: { ...SPAN, span_id: "next-span" } });
await flushPromises();
expect(callsFor("next-span")).toHaveLength(3);
pending.splice(0).forEach((resolve) => resolve());
await flushPromises();
expect(callsFor(SPAN.span_id)).toHaveLength(3);
expect(callsFor("next-span")).toHaveLength(5);
w.unmount();
});
it("navigates to the first matching stream's flame graph, preferring CPU", async () => {
setStreams({ first: FULL_SCHEMA, second: FULL_SCHEMA });
profileHits.first = [
{ profile_type: "alloc_space", profile_unit: "bytes", service_name: "worker", c: 9 },
{ profile_type: "cpu", profile_unit: "nanoseconds", service_name: "worker", c: 2 },
];
profileHits.second = [
{ profile_type: "cpu", profile_unit: "nanoseconds", service_name: "other", c: 5 },
];
const push = vi.spyOn(router, "push").mockResolvedValue(undefined);
const w = await mountWith({ trace_id: "t,1", span_id: "s=1" });
await w.find(BUTTON).trigger("click");
expect(push).toHaveBeenCalledWith({
name: "profiles",
query: {
org_identifier: "test-org",
stream: "first",
from: String(WINDOW_START),
to: String(WINDOW_END),
service_name: "worker",
profile_type: "cpu",
profile_unit: "nanoseconds",
filters: "trace_id=t%2C1,span_id=s%3D1",
view: "flame",
},
});
push.mockRestore();
w.unmount();
});
it("omits service_name from the navigation when the profile stream has none", async () => {
setStreams({ prof: ["trace_id", "span_id"] });
profileHits.prof = [{ profile_type: "cpu", profile_unit: "nanoseconds", c: 1 }];
const push = vi.spyOn(router, "push").mockResolvedValue(undefined);
const w = await mountWith();
await w.find(BUTTON).trigger("click");
expect(push.mock.calls[0][0]).toMatchObject({ name: "profiles" });
expect((push.mock.calls[0][0] as any).query).not.toHaveProperty("service_name");
push.mockRestore();
w.unmount();
});
it("detects once per span and lists the streams once", async () => {
setStreams({ prof: FULL_SCHEMA });
profileHits.prof = [{ profile_type: "cpu", profile_unit: "nanoseconds", c: 1 }];
const w = await mountWith();
await w.setProps({ span: { ...SPAN, span_id: "other-span" } });
await flushPromises();
await w.setProps({ span: { ...SPAN } });
await flushPromises();
expect(profileCalls()).toHaveLength(2);
expect(mockStreamNameList).toHaveBeenCalledTimes(1);
expect(w.find(BUTTON).exists()).toBe(true);
w.unmount();
});
});
@@ -100,6 +100,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="text-3xs text-text-body font-semibold">{{ getDuration }}</span>
</OTag>
<span v-if="operationPercentile" class="me-[0.325rem] inline-flex shrink-0">
<OTag
:variant="operationPercentile.variant"
size="sm"
data-test="trace-details-sidebar-header-toolbar-operation-percentile"
>{{
t("traces.traceDetailsSidebar.operationPercentileBand", {
band: operationPercentile.band,
operation: span.operation_name,
})
}}</OTag
>
<OTooltip
:content="operationPercentile.tooltip"
max-width="none"
content-class="whitespace-nowrap"
/>
</span>
<!-- TTFT Badge -->
<OTag
v-if="getTTFT"
@@ -195,6 +214,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<OTooltip :content="viewLogsTooltipContent" />
</span>
<OButton
v-if="spanProfileMatch"
variant="outline"
size="xs"
class="ms-1 h-full text-xs!"
data-test="trace-details-sidebar-header-toolbar-view-profile-btn"
@click.stop="viewSpanProfile"
>
{{ t("traces.viewProfile") }}
</OButton>
</span>
<!-- LLM workflow actions — icon-only, matching the trace header. The
@@ -1039,9 +1068,54 @@ import type { Span } from "@/ts/interfaces/traces/span.types";
import { getOrSetServiceColor } from "@/utils/traces/serviceColorRegistry";
import llmPromptsService from "@/services/llm-prompts.service";
import { aiPromptsRoute } from "@/views/AIObservability/promptRoutes";
import searchService from "@/services/search";
import { quoteSqlIdentifier, quoteSqlLiteral } from "@/utils/telemetryCorrelation";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
import { queryClient } from "@/composables/query/queryClient";
import { streamNameListQuery, streamSchemaQuery } from "@/services/stream.queries";
import { encodeProfileFilters } from "@/services/profiles";
// luxon equivalent of "MMM DD, YYYY HH:mm:ss.SSS Z" → e.g. "Jun 24, 2026 17:39:32.157 +0530"
const HUMAN_TZ_FORMAT = "MMM dd, yyyy HH:mm:ss.SSS ZZZ";
const OPERATION_STATS_MIN_SAMPLES = 20;
const OPERATION_STATS_FALLBACK_WINDOW_US = 3_600_000_000;
const OPERATION_STATS_FALLBACK_BUCKET_US = 600_000_000;
const PROFILE_LINK_PAD_US = 60_000_000;
const PROFILE_LINK_CONCURRENCY = 3;
interface OperationStats {
n: number;
p50: number;
p75: number;
p90: number;
p99: number;
}
interface OperationStatsRequest {
key: string;
org: string;
stream: string;
service: string;
operation: string;
startTime: number;
endTime: number;
}
interface SpanProfileRequest {
key: string;
org: string;
traceId: string;
spanId: string;
startTime: number;
endTime: number;
}
interface SpanProfileMatch {
stream: string;
profileType: string;
profileUnit: string;
serviceName?: string;
}
export default defineComponent({
name: "TraceDetailsSidebar",
@@ -1829,6 +1903,240 @@ export default defineComponent({
/** The trace stream this span was read from — the annotation API needs it. */
const spanSourceStream = computed(() => String(props.span?._stream ?? props.streamName ?? ""));
const operationStatsCache = ref<Record<string, OperationStats | null>>({});
const pendingOperationStats = new Set<string>();
const operationStatsRequest = computed((): OperationStatsRequest | null => {
const stream = spanSourceStream.value;
const service = props.span?.service_name;
const operation = props.span?.operation_name;
if (!stream || !service || !operation) return null;
const saved = searchObj.data?.queryPayload?.query;
// Re-resolving a relative range would move the window, so reuse the bounds the page searched.
const usePageBounds =
props.parentMode !== "embedded" &&
stream === searchObj.data?.stream?.selectedStream?.value &&
saved?.start_time &&
saved?.end_time;
let startTime: number;
let endTime: number;
if (usePageBounds) {
startTime = Number(saved.start_time);
endTime = Number(saved.end_time);
} else {
const spanStartUs = Math.floor(Number(props.span?.start_time) / 1_000);
if (!Number.isFinite(spanStartUs)) return null;
// Bucketing the span start lets nearby spans share one cached window that still contains each.
const bucketUs =
Math.floor(spanStartUs / OPERATION_STATS_FALLBACK_BUCKET_US) *
OPERATION_STATS_FALLBACK_BUCKET_US;
startTime = bucketUs - OPERATION_STATS_FALLBACK_WINDOW_US;
endTime =
bucketUs + OPERATION_STATS_FALLBACK_WINDOW_US + OPERATION_STATS_FALLBACK_BUCKET_US;
}
const org = String(store.state.selectedOrganization?.identifier ?? "");
const key = JSON.stringify([org, stream, service, operation, startTime, endTime]);
return { key, org, stream, service, operation, startTime, endTime };
});
const fetchOperationStats = async (req: OperationStatsRequest) => {
if (req.key in operationStatsCache.value || pendingOperationStats.has(req.key)) return;
pendingOperationStats.add(req.key);
const sql =
"SELECT COUNT(*) AS n, " +
"approx_percentile_cont(duration, 0.5) AS p50, " +
"approx_percentile_cont(duration, 0.75) AS p75, " +
"approx_percentile_cont(duration, 0.9) AS p90, " +
"approx_percentile_cont(duration, 0.99) AS p99 " +
`FROM ${quoteSqlIdentifier(req.stream)} ` +
`WHERE service_name = ${quoteSqlLiteral(req.service)} ` +
`AND operation_name = ${quoteSqlLiteral(req.operation)}`;
try {
const res = await searchService.search({
org_identifier: req.org,
query: {
query: { sql, start_time: req.startTime, end_time: req.endTime, from: 0, size: 1 },
},
page_type: "traces",
});
const hit = res?.data?.hits?.[0];
operationStatsCache.value[req.key] = hit
? {
n: Number(hit.n),
p50: Number(hit.p50),
p75: Number(hit.p75),
p90: Number(hit.p90),
p99: Number(hit.p99),
}
: null;
} catch {
operationStatsCache.value[req.key] = null;
} finally {
pendingOperationStats.delete(req.key);
}
};
watch(
operationStatsRequest,
(req) => {
if (req) fetchOperationStats(req);
},
{ immediate: true },
);
const operationPercentile = computed(() => {
const req = operationStatsRequest.value;
const stats = req ? operationStatsCache.value[req.key] : null;
const duration = Number(props.span?.duration);
// approx_percentile_cont returns 0 when the column type is not inferred.
if (!stats || !(stats.n >= OPERATION_STATS_MIN_SAMPLES) || !(stats.p50 > 0)) return null;
if (!Number.isFinite(duration)) return null;
let band = t("traces.traceDetailsSidebar.operationBandAboveP99");
let variant: BadgeVariant = "error-soft";
if (duration < stats.p50) {
[band, variant] = [t("traces.traceDetailsSidebar.operationBandBelowP50"), "default-soft"];
} else if (duration < stats.p75) {
[band, variant] = [t("traces.traceDetailsSidebar.operationBandP50ToP75"), "default-soft"];
} else if (duration < stats.p90) {
[band, variant] = [t("traces.traceDetailsSidebar.operationBandP75ToP90"), "default-soft"];
} else if (duration <= stats.p99) {
[band, variant] = [t("traces.traceDetailsSidebar.operationBandP90ToP99"), "warning-soft"];
}
const tooltip = t("traces.traceDetailsSidebar.operationPercentilesTooltip", {
p50: formatTimeWithSuffix(stats.p50),
p75: formatTimeWithSuffix(stats.p75),
p90: formatTimeWithSuffix(stats.p90),
p99: formatTimeWithSuffix(stats.p99),
n: stats.n.toLocaleString(),
});
return { band, variant, tooltip };
});
const spanProfileCache = ref<Record<string, SpanProfileMatch | null>>({});
const pendingSpanProfiles = new Set<string>();
const spanProfileRequest = computed((): SpanProfileRequest | null => {
const traceId = String(props.span?.trace_id ?? "");
const spanId = String(props.span?.span_id ?? "");
if (props.parentMode !== "standalone" || !traceId || !spanId) return null;
const startUs = Math.floor(Number(props.span?.start_time) / 1_000);
const endUs = Math.ceil(Number(props.span?.end_time) / 1_000);
if (!Number.isFinite(startUs)) return null;
const org = String(store.state.selectedOrganization?.identifier ?? "");
return {
key: JSON.stringify([org, traceId, spanId]),
org,
traceId,
spanId,
startTime: startUs - PROFILE_LINK_PAD_US,
endTime: (Number.isFinite(endUs) ? endUs : startUs) + PROFILE_LINK_PAD_US,
};
});
const probeProfileStream = async (
req: SpanProfileRequest,
stream: string,
): Promise<SpanProfileMatch | null> => {
try {
const schema = await queryClient.fetchQuery(streamSchemaQuery(req.org, stream, "profiles"));
const fields = new Set(
(schema?.schema || schema?.fields || []).map((f: { name: string }) => f.name),
);
if (!fields.has("trace_id") || !fields.has("span_id")) return null;
const hasService = fields.has("service_name");
const columns = hasService
? "profile_type, profile_unit, service_name"
: "profile_type, profile_unit";
const sql =
`SELECT ${columns}, COUNT(*) AS c FROM ${quoteSqlIdentifier(stream)} ` +
`WHERE trace_id = ${quoteSqlLiteral(req.traceId)} ` +
`AND span_id = ${quoteSqlLiteral(req.spanId)} GROUP BY ${columns}`;
const res = await searchService.search({
org_identifier: req.org,
query: {
query: { sql, start_time: req.startTime, end_time: req.endTime, from: 0, size: 100 },
},
page_type: "profiles",
});
const hits: Record<string, unknown>[] = res?.data?.hits ?? [];
const hit = hits.find((h) => String(h.profile_type).toLowerCase() === "cpu") ?? hits[0];
if (!hit) return null;
return {
stream,
profileType: String(hit.profile_type ?? ""),
profileUnit: String(hit.profile_unit ?? ""),
serviceName: hasService && hit.service_name ? String(hit.service_name) : undefined,
};
} catch {
return null;
}
};
const findSpanProfile = async (req: SpanProfileRequest) => {
const list = await queryClient.fetchQuery(streamNameListQuery(req.org, "profiles"));
const streams = list
.map((item: Record<string, unknown>) => String(item.name ?? item.stream_name ?? ""))
.filter(Boolean);
for (let i = 0; i < streams.length; i += PROFILE_LINK_CONCURRENCY) {
if (spanProfileRequest.value?.key !== req.key) return undefined;
const batch = streams.slice(i, i + PROFILE_LINK_CONCURRENCY);
const matches = await Promise.all(batch.map((s: string) => probeProfileStream(req, s)));
const match = matches.find(Boolean);
if (match) return match;
}
return null;
};
const detectSpanProfile = async (req: SpanProfileRequest) => {
if (req.key in spanProfileCache.value || pendingSpanProfiles.has(req.key)) return;
pendingSpanProfiles.add(req.key);
try {
const match = await findSpanProfile(req);
// An abandoned probe proved nothing, so it must not cache a miss.
if (match !== undefined) spanProfileCache.value[req.key] = match;
} catch {
spanProfileCache.value[req.key] = null;
} finally {
pendingSpanProfiles.delete(req.key);
}
};
watch(
spanProfileRequest,
(req) => {
if (req) detectSpanProfile(req);
},
{ immediate: true },
);
const spanProfileMatch = computed(() => {
const req = spanProfileRequest.value;
return req ? (spanProfileCache.value[req.key] ?? null) : null;
});
const viewSpanProfile = () => {
const req = spanProfileRequest.value;
const match = spanProfileMatch.value;
if (!req || !match) return;
router.push({
name: "profiles",
query: {
org_identifier: req.org,
stream: match.stream,
from: String(req.startTime),
to: String(req.endTime),
...(match.serviceName ? { service_name: match.serviceName } : {}),
profile_type: match.profileType,
profile_unit: match.profileUnit,
filters: encodeProfileFilters([
{ key: "trace_id", op: "=", value: req.traceId },
{ key: "span_id", op: "=", value: req.spanId },
]),
view: "flame",
},
});
};
/** Span start in MICROSECONDS (`start_time` is nanoseconds), widened by 1µs
* so an inclusive lower-bound search can't exclude the span itself. A span
* with no usable start falls back to the trace's own start: the APIs take
@@ -2411,6 +2719,9 @@ export default defineComponent({
spanHttpResendCount,
navigateToError,
getDuration,
operationPercentile,
spanProfileMatch,
viewSpanProfile,
getTTFT,
viewSpanLogs,
evaluateSpan,
+9
View File
@@ -846,6 +846,15 @@ describe("TraceTree", () => {
const spanBlocks = wrapper.findAllComponents({ name: "span-block" });
expect(spanBlocks.length).toBe(0);
});
it("passes showCriticalPath through to every span block", async () => {
await wrapper.setProps({ showCriticalPath: true });
await flushPromises();
const spanBlocks = wrapper.findAllComponents({ name: "span-block" });
expect(spanBlocks.length).toBe(mockSpans.length);
spanBlocks.forEach((block: any) => expect(block.props("showCriticalPath")).toBe(true));
});
});
describe("Error handling", () => {
+5
View File
@@ -340,6 +340,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:spanDimensions="spanDimensions"
:isCollapsed="collapseMapping[(spans as any[])[virtualRow.index].spanId]"
:spanData="spanMap[(spans as any[])[virtualRow.index].spanId]"
:showCriticalPath="showCriticalPath"
@toggle-collapse="toggleSpanCollapse"
@select-span="selectSpan"
@select-span-event="selectSpanEvent"
@@ -442,6 +443,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
showCriticalPath: {
type: Boolean,
default: false,
},
scrollContainer: {
// DOM Element ref from parent — no runtime type check needed
default: null,
@@ -293,6 +293,32 @@ describe("TracesAnalysisDashboard", () => {
expect(drawer.props("width")).toBe(80);
});
it("should render the full-page shell instead of ODrawer when fullPage is set", async () => {
wrapper.unmount();
wrapper = mountComponent({ fullPage: true });
await flushPromises();
expect(wrapper.findComponent({ name: "ODrawer" }).exists()).toBe(false);
const page = wrapper.findComponent({ name: "TracesDrillDownPage" });
expect(page.exists()).toBe(true);
expect(page.attributes("data-test")).toBe("traces-analysis-dashboard-drawer");
expect(page.props("title")).toBe("Drill down");
});
it("should emit close when the full-page Back button is clicked", async () => {
wrapper.unmount();
wrapper = mountComponent({ fullPage: true });
await flushPromises();
await wrapper.find('[data-test="traces-drill-down-back-btn"]').trigger("click");
expect(wrapper.emitted("close")).toHaveLength(1);
});
it("should keep the analysis-type title on the drawer (logs) rather than Drill down", () => {
const drawer = wrapper.findComponent({ name: "ODrawer" });
expect(drawer.props("title")).not.toBe("Drill down");
});
it("should pass a non-empty title to ODrawer for the 'duration' analysisType", () => {
const drawer = wrapper.findComponent({ name: "ODrawer" });
expect(drawer.props("title")).toBeTruthy();
@@ -15,13 +15,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<ODrawer
<component
:is="fullPage ? TracesDrillDownPage : ODrawer"
data-test="traces-analysis-dashboard-drawer"
bleed
v-bind="fullPage ? {} : { bleed: true, width: 80 }"
v-model:open="isOpen"
:width="80"
:title="raw(drawerTitle)"
@update:open="(v) => !v && onClose()"
:title="fullPage ? t('traces.drillDown') : raw(drawerTitle)"
@update:open="(v: boolean) => !v && onClose()"
>
<template #header-left>
<OIcon name="timeline" size="md" />
@@ -298,7 +298,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OSplitter>
</div>
</div>
</ODrawer>
</component>
</template>
<script lang="ts" setup>
@@ -307,6 +307,7 @@ import OTab from "@/lib/navigation/Tabs/OTab.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import TracesDrillDownPage from "./TracesDrillDownPage.vue";
import { ref, computed, watch, defineAsyncComponent, nextTick } from "vue";
import { useStore } from "vuex";
import useTheme from "@/composables/useTheme";
@@ -372,6 +373,7 @@ interface Props {
availableAnalysisTypes?: Array<"duration" | "volume" | "error">; // Which tabs to show
streamFields?: any[]; // Stream schema fields for smart dimension selection
logSamples?: any[]; // Actual log data for sample-based analysis (logs only)
fullPage?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -0,0 +1,160 @@
// 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/>.
import { describe, expect, it, afterEach, vi } from "vitest";
import { mount, type VueWrapper } from "@vue/test-utils";
import TracesDrillDownPage from "./TracesDrillDownPage.vue";
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (k: string) => k }),
}));
function mountPage(props: Record<string, unknown> = {}): VueWrapper<any> {
return mount(TracesDrillDownPage, {
attachTo: document.body,
props: { open: true, title: "Volume Insights", ...props },
slots: {
"header-left": '<span data-test="header-left-content" />',
default: '<div data-test="body-content"><input data-test="inner-input" /></div>',
},
});
}
describe("TracesDrillDownPage", () => {
let wrapper: VueWrapper<any>;
afterEach(() => {
wrapper?.unmount();
});
it("should render the back button, title, header and body slots", () => {
wrapper = mountPage();
expect(wrapper.find('[data-test="traces-drill-down-back-btn"]').text()).toContain(
"traces.backToResults",
);
expect(wrapper.text()).toContain("Volume Insights");
expect(wrapper.find('[data-test="header-left-content"]').exists()).toBe(true);
expect(wrapper.find('[data-test="body-content"]').exists()).toBe(true);
});
it("should focus the Back button on mount", () => {
wrapper = mountPage();
expect(document.activeElement).toBe(
wrapper.find('[data-test="traces-drill-down-back-btn"]').element,
);
});
it("should return focus to the Drill down button when focus was on the page at close", () => {
const drillDown = document.createElement("button");
drillDown.setAttribute("data-test", "insights-button");
document.body.appendChild(drillDown);
wrapper = mountPage();
wrapper.unmount();
expect(document.activeElement).toBe(drillDown);
drillDown.remove();
});
it("should leave focus alone when it was outside the page at close", () => {
const drillDown = document.createElement("button");
drillDown.setAttribute("data-test", "insights-button");
const editor = document.createElement("input");
document.body.append(drillDown, editor);
wrapper = mountPage();
editor.focus();
wrapper.unmount();
expect(document.activeElement).toBe(editor);
drillDown.remove();
editor.remove();
});
it("should make the content it covers inert while open, and restore it on close", () => {
const container = document.createElement("div");
const covered = document.createElement("div");
const alreadyInert = document.createElement("div");
alreadyInert.setAttribute("inert", "");
container.append(covered, alreadyInert);
document.body.appendChild(container);
wrapper = mount(TracesDrillDownPage, {
attachTo: container,
props: { open: true, title: "Volume Insights" },
});
expect(covered.hasAttribute("inert")).toBe(true);
expect(wrapper.element.hasAttribute("inert")).toBe(false);
wrapper.unmount();
expect(covered.hasAttribute("inert")).toBe(false);
expect(alreadyInert.hasAttribute("inert")).toBe(true);
container.remove();
});
it("should render nothing when closed", () => {
wrapper = mountPage({ open: false });
expect(wrapper.find('[data-test="traces-drill-down-back-btn"]').exists()).toBe(false);
});
it("should emit update:open=false when Back is clicked", async () => {
wrapper = mountPage();
await wrapper.find('[data-test="traces-drill-down-back-btn"]').trigger("click");
expect(wrapper.emitted("update:open")).toEqual([[false]]);
});
it("should close on Escape pressed inside the page", async () => {
wrapper = mountPage();
const input = wrapper.find('[data-test="inner-input"]').element as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(wrapper.emitted("update:open")).toEqual([[false]]);
});
it("should ignore Escape from elements outside the page, such as teleported popovers", async () => {
wrapper = mountPage();
const outside = document.createElement("div");
document.body.appendChild(outside);
outside.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(wrapper.emitted("update:open")).toBeUndefined();
outside.remove();
});
it("should ignore Escape another handler already consumed", async () => {
wrapper = mountPage();
const input = wrapper.find('[data-test="inner-input"]').element as HTMLInputElement;
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
event.preventDefault();
input.dispatchEvent(event);
expect(wrapper.emitted("update:open")).toBeUndefined();
});
it("should stop listening for Escape after unmount", async () => {
wrapper = mountPage();
const emitted = wrapper.emitted();
wrapper.unmount();
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(emitted["update:open"]).toBeUndefined();
});
});
@@ -0,0 +1,103 @@
<!-- 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
v-if="open"
ref="rootRef"
class="bg-surface-panel flex h-full w-full flex-col overflow-hidden"
>
<div class="border-border-default flex shrink-0 items-center gap-2 border-b px-3 py-2">
<OButton
ref="backBtnRef"
data-test="traces-drill-down-back-btn"
variant="outline"
size="xs"
icon-left="arrow-back"
@click="close"
>
{{ t("traces.backToResults") }}
</OButton>
<span class="text-text-heading min-w-0 truncate text-base font-semibold">{{ title }}</span>
<div class="flex min-w-0 flex-1 items-center gap-2">
<slot name="header-left" />
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<slot />
</div>
</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from "vue";
import OButton from "@/lib/core/Button/OButton.vue";
import { useI18nTyped } from "@/types/i18n";
defineProps<{
open: boolean;
title?: string;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
}>();
const { t } = useI18nTyped();
const rootRef = ref<HTMLElement | null>(null);
const backBtnRef = ref<{ $el?: HTMLElement } | null>(null);
const close = () => emit("update:open", false);
// Content this page covers stays in the DOM to keep its state, so it must not take focus.
const coveredSiblings: Element[] = [];
const setCoveredInert = (inert: boolean) => {
if (!inert) {
coveredSiblings.splice(0).forEach((el) => el.removeAttribute("inert"));
return;
}
const host = rootRef.value?.parentElement;
for (const el of Array.from(host?.parentElement?.children ?? [])) {
if (el !== host && !el.hasAttribute("inert")) {
el.setAttribute("inert", "");
coveredSiblings.push(el);
}
}
};
const onKeydown = (e: KeyboardEvent) => {
if (e.key !== "Escape" || e.defaultPrevented) return;
const target = e.target as Node | null;
// Popovers opened from the page are teleported to body and own their Escape.
if (target === document.body || (target && rootRef.value?.contains(target))) close();
};
onMounted(() => {
setCoveredInert(true);
document.addEventListener("keydown", onKeydown);
backBtnRef.value?.$el?.focus?.();
});
onBeforeUnmount(() => {
// Only a Back/Escape close leaves focus here; a close caused by a new search must not steal it.
const active = document.activeElement;
const focusWasOnPage = active === document.body || !!rootRef.value?.contains(active);
setCoveredInert(false);
document.removeEventListener("keydown", onKeydown);
if (focusWasOnPage) {
document.querySelector<HTMLElement>('[data-test="insights-button"]')?.focus();
}
});
</script>
@@ -44,6 +44,7 @@ vi.mock("./TracesAnalysisDashboard.vue", () => ({
// ---------------------------------------------------------------------------
const mockMetricsRangeFilters = new Map();
const mockSearchObj = reactive({
loading: false,
data: {
editorValue: "",
datetime: { startTime: 1_000_000, endTime: 2_000_000 },
@@ -138,6 +139,7 @@ function mountComponent(props: Record<string, unknown> = {}): VueWrapper<any> {
global: {
plugins: [mockStore, i18n],
stubs: {
teleport: true,
RenderDashboardCharts: {
template: '<div data-test="render-dashboard-charts"></div>',
},
@@ -146,7 +148,13 @@ function mountComponent(props: Record<string, unknown> = {}): VueWrapper<any> {
},
TracesAnalysisDashboard: {
template: '<div data-test="traces-analysis-dashboard"></div>',
props: ["streamName", "streamType", "timeRange", "analysisType"],
props: {
streamName: null,
streamType: null,
timeRange: null,
analysisType: null,
fullPage: Boolean,
},
emits: ["close"],
},
},
@@ -175,6 +183,7 @@ describe("TracesMetricsDashboard", () => {
beforeEach(async () => {
// Reset all shared state before every test
mockMetricsRangeFilters.clear();
mockSearchObj.loading = false;
mockSearchObj.data.editorValue = "";
mockSearchObj.meta.showHistogram = true;
mockSearchObj.meta.searchMode = "traces";
@@ -592,6 +601,47 @@ describe("TracesMetricsDashboard", () => {
expect(analysisDashboard.exists()).toBe(true);
});
it("should render it full page, teleported into the traces drill-down target", async () => {
wrapper.vm.openUnifiedAnalysisDashboard();
await flushPromises();
const teleport = wrapper.find("teleport-stub");
expect(teleport.attributes("to")).toBe("#traces-drill-down-page");
const analysisDashboard = wrapper.findComponent('[data-test="traces-analysis-dashboard"]');
expect(analysisDashboard.props("fullPage")).toBe(true);
});
it("should unmount the analysis dashboard when it emits close", async () => {
wrapper.vm.openUnifiedAnalysisDashboard();
await flushPromises();
wrapper.findComponent('[data-test="traces-analysis-dashboard"]').vm.$emit("close");
await flushPromises();
expect(wrapper.find("teleport-stub").exists()).toBe(false);
expect(wrapper.vm.showAnalysisDashboard).toBe(false);
});
it("should close the analysis dashboard when a new search starts", async () => {
wrapper.vm.openUnifiedAnalysisDashboard();
await flushPromises();
mockSearchObj.loading = true;
await flushPromises();
expect(wrapper.vm.showAnalysisDashboard).toBe(false);
expect(wrapper.find("teleport-stub").exists()).toBe(false);
});
it("should keep the analysis dashboard open when a search finishes", async () => {
mockSearchObj.loading = true;
await flushPromises();
wrapper.vm.openUnifiedAnalysisDashboard();
await flushPromises();
mockSearchObj.loading = false;
await flushPromises();
expect(wrapper.vm.showAnalysisDashboard).toBe(true);
});
it("should set defaultAnalysisTab to volume when no brush selection exists", async () => {
wrapper.vm.openUnifiedAnalysisDashboard();
await flushPromises();
@@ -50,26 +50,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@close="hideContextMenu"
/>
<!-- Unified Analysis Dashboard with Tabs -->
<TracesAnalysisDashboard
v-if="showAnalysisDashboard"
:streamName="streamName"
streamType="traces"
:timeRange="originalTimeRangeBeforeSelection || effectiveTimeRange"
:rateFilter="analysisRateFilter"
:durationFilter="analysisDurationFilter"
:errorFilter="analysisErrorFilter"
:baseFilter="parsedEffectiveFilter"
:streamFields="streamFields"
:analysisType="defaultAnalysisTab"
:availableAnalysisTypes="['volume', 'error', 'duration']"
@close="showAnalysisDashboard = false"
/>
<Teleport v-if="showAnalysisDashboard" defer to="#traces-drill-down-page">
<TracesAnalysisDashboard
full-page
:streamName="streamName"
streamType="traces"
:timeRange="originalTimeRangeBeforeSelection || effectiveTimeRange"
:rateFilter="analysisRateFilter"
:durationFilter="analysisDurationFilter"
:errorFilter="analysisErrorFilter"
:baseFilter="parsedEffectiveFilter"
:streamFields="streamFields"
:analysisType="defaultAnalysisTab"
:availableAnalysisTypes="['volume', 'error', 'duration']"
@close="showAnalysisDashboard = false"
/>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, computed, defineAsyncComponent, nextTick } from "vue";
import {
ref,
onMounted,
onBeforeUnmount,
computed,
defineAsyncComponent,
nextTick,
watch,
} from "vue";
import { useStore } from "vuex";
import { useI18nTyped, raw } from "@/types/i18n";
import useNotifications from "@/composables/useNotifications";
@@ -162,6 +171,13 @@ interface AnalysisFilter {
timeEnd?: number;
}
const showAnalysisDashboard = ref(false);
// The analysis is a snapshot of the search that opened it, so a new search closes it.
watch(
() => searchObj.loading,
(loading, wasLoading) => {
if (loading && !wasLoading) showAnalysisDashboard.value = false;
},
);
const analysisDurationFilter = ref<AnalysisFilter | undefined>({ start: 0, end: 0 });
const analysisRateFilter = ref<AnalysisFilter | undefined>({ start: 0, end: 0 });
const analysisErrorFilter = ref<AnalysisFilter | undefined>({ start: 0, end: 0 });
+28 -5
View File
@@ -84,6 +84,33 @@ export interface ProfilesQueryBody {
timeout?: number;
}
const decodeFilterPart = (part: string) => {
try {
return decodeURIComponent(part);
} catch {
// Matches the backend, which keeps a malformed escape verbatim.
return part;
}
};
/** Encodes filters as the backend's `k=v,k2=v2`, percent-encoding each side so a comma inside a value is not a pair separator. */
export const encodeProfileFilters = (filters: ProfileFilter[]): string =>
filters
.filter((f) => f.key && f.value && f.op === "=")
.map((f) => `${encodeURIComponent(f.key)}=${encodeURIComponent(f.value)}`)
.join(",");
export const parseProfileFilters = (raw: string): ProfileFilter[] =>
raw
.split(",")
.map((pair) => pair.split("="))
.filter(([key, value]) => key && value)
.map(([key, value]) => ({
key: decodeFilterPart(key),
op: "=" as const,
value: decodeFilterPart(value),
}));
const appendProfilesQuery = (
query: URLSearchParams,
payload: Omit<ProfilesQueryBody, "filters" | "step" | "max_nodes"> & {
@@ -99,11 +126,7 @@ const appendProfilesQuery = (
if (payload.profile_unit) query.set("profile_unit", payload.profile_unit);
if (payload.tag) query.set("tag", payload.tag);
if (payload.filters?.length) {
// Percent-encode each side so a comma inside a value is not a pair separator.
const filters = payload.filters
.filter((f) => f.key && f.value && f.op === "=")
.map((f) => `${encodeURIComponent(f.key)}=${encodeURIComponent(f.value)}`)
.join(",");
const filters = encodeProfileFilters(payload.filters);
if (filters) query.set("filters", filters);
}
};
+1
View File
@@ -71,6 +71,7 @@ const organizationObj = {
free_trial_expiry: "",
cross_links: [],
usage_stream_enabled: false,
red_insights_enabled: false,
},
isDataIngested: false,
regexPatterns: [],
@@ -86,7 +86,6 @@ export interface EnrichedSpan extends Span {
// UI state
isExpanded: boolean; // Tree expansion state
isSelected: boolean; // Selected in UI
isOnCriticalPath: boolean; // Part of critical path
// Computed values
resolvedIdentity: string; // e.g. "postgresql:orders", "kafka:user-events", "checkout-api"
+1 -113
View File
@@ -3,7 +3,7 @@
* Types for distributed traces and trace metadata
*/
import { type Span, type EnrichedSpan, type SpanStatus } from "./span.types";
import { type Span, type SpanStatus } from "./span.types";
/**
* Search mode for the traces explorer
@@ -45,118 +45,6 @@ export interface Trace {
errorCount: number; // Number of error spans
}
/**
* Trace Tree - Hierarchical representation of spans
*/
export interface TraceTree {
trace_id: string;
rootSpan: EnrichedSpan;
flatSpans: EnrichedSpan[]; // All spans in flat array
spanMap: Map<string, EnrichedSpan>; // Quick lookup by span_id
criticalPath: string[]; // Span IDs on critical path
metadata: TraceMetadata;
}
/**
* Trace Metadata - Aggregated information about the trace
*/
export interface TraceMetadata {
// Identifiers
trace_id: string;
root_service: string;
root_operation: string;
// Timing
start_time: number;
end_time: number;
duration_ms: number;
// Counts
total_spans: number;
error_spans: number;
service_count: number;
// Services involved
services: string[];
service_spans: Map<string, number>; // service -> span count
service_durations: Map<string, number>; // service -> total duration
// Span kind breakdown
span_kinds: Map<string, number>; // kind -> count
// Status breakdown
status_counts: {
ok: number;
error: number;
unset: number;
};
// Performance
critical_path_duration: number; // Duration of longest path
critical_path_percent: number; // % of total duration
slowest_spans: EnrichedSpan[]; // Top 5 slowest spans
// Error information
has_errors: boolean;
error_services: string[]; // Services with errors
error_messages: string[]; // Unique error messages
}
/**
* Trace Statistics - For analytics panel
*/
export interface TraceStatistics {
// Overview
total_duration_ms: number;
total_spans: number;
services_count: number;
error_rate: number; // Percentage
// Service breakdown
service_breakdown: ServiceBreakdown[];
// Span kind distribution
span_kind_distribution: {
kind: string;
count: number;
percentage: number;
}[];
// Performance metrics
avg_span_duration: number;
median_span_duration: number;
p95_span_duration: number;
p99_span_duration: number;
// Database & external calls
database_calls: number;
external_api_calls: number;
internal_calls: number;
}
/**
* Service Breakdown - Time spent per service
*/
export interface ServiceBreakdown {
service_name: string;
span_count: number;
total_duration_ms: number;
percentage: number;
color: string; // From color palette
has_errors: boolean;
error_count: number;
}
/**
* Critical Path - Longest chain of dependent spans
*/
export interface CriticalPath {
span_ids: string[];
spans: EnrichedSpan[];
total_duration: number;
percentage_of_trace: number;
}
/**
* Trace View State - UI state for trace details view
*/
@@ -304,6 +304,89 @@ describe("buildAnomalyBandOptions", () => {
});
});
describe("buildAnomalyBandOptions units", () => {
const tooltipOf = (over: Partial<AnomalyBandRow>, unit?: string) =>
buildAnomalyBandOptions([row(1000, over)], LABELS, COLORS, SPAN, null, unit).tooltip.formatter([
{ axisValue: 1000, axisValueLabel: "00:00" },
]);
const axisLabel = (value: number, unit?: string) =>
buildAnomalyBandOptions(
[row(1000)],
LABELS,
COLORS,
SPAN,
null,
unit,
).yAxis.axisLabel.formatter(value);
it("labels the y-axis compactly when no unit is known", () => {
expect(axisLabel(939_000)).toBe("939K");
expect(axisLabel(1_100_000)).toBe("1.1M");
expect(axisLabel(12)).toBe("12");
expect(axisLabel(12.5)).toBe("12.5");
});
it("labels the y-axis in time units for microseconds", () => {
expect(axisLabel(800_000, "microseconds")).toBe("800ms");
expect(axisLabel(1_100_000, "microseconds")).toBe("1.1s");
});
it("moves a tick that rounds up to 1,000 into the next step", () => {
expect(axisLabel(999_999)).toBe("1M");
expect(axisLabel(999_999, "microseconds")).toBe("1s");
});
it("scales tooltip readings to microsecond time units", () => {
const html = tooltipOf(
{ value: 938_661, lower: 801_027, upper: 1_083_743, expected: 942_385 },
"microseconds",
);
expect(html).toContain("Value: 939ms");
expect(html).toContain("Expected range: [801ms, 1.08s]");
expect(html).toContain("Expected: 942ms");
});
it("shows tooltip readings compactly when no unit is known", () => {
const html = tooltipOf({ value: 938_661, lower: 801_027, upper: 1_083_743, expected: 942_385 });
expect(html).toContain("Value: 939K");
expect(html).toContain("Expected range: [801K, 1.08M]");
expect(html).toContain("Expected: 942K");
});
it("widens precision after scaling when a value and its bound would read alike", () => {
const html = tooltipOf({ value: 100_490, lower: 90_000, upper: 100_480 });
expect(html).toContain("Value: 100.49K");
expect(html).toContain("Expected range: [90.00K, 100.48K]");
});
it("widens on the exact scaled value for a near-equal billions pair", () => {
const html = tooltipOf({ value: 1_234_567_892, lower: 1_000_000_000, upper: 1_234_567_891 });
expect(html).toContain("Value: 1.234567892B");
expect(html).toContain("Expected range: [1.000000000B, 1.234567891B]");
});
it("widens on the exact scaled value for a near-equal minutes pair", () => {
const html = tooltipOf(
{ value: 61_000_030, lower: 50_000_000, upper: 61_000_000 },
"microseconds",
);
expect(html).toContain("Value: 1.0166672m");
expect(html).toContain("Expected range: [50.0000000s, 1.0166667m]");
});
it("keeps tiny readings apart instead of rounding them to zero", () => {
const html = tooltipOf({ value: 4e-7, lower: 1e-7, upper: 2e-7 });
expect(html).toContain("Value: 0.0000004");
expect(html).toContain("Expected range: [0.0000001, 0.0000002]");
});
it("moves a reading that rounds up to 1,000 into the next step", () => {
expect(tooltipOf({ value: 999_999.6 })).toContain("Value: 1.00M");
expect(tooltipOf({ value: 999_999.6 }, "microseconds")).toContain("Value: 1.00s");
expect(tooltipOf({ value: -999_999.6 })).toContain("Value: -1.00M");
});
});
describe("formatReading", () => {
it("formats by magnitude so a value and its bounds read alike", () => {
expect(formatReading(4060.45)).toBe("4,060");
+50 -11
View File
@@ -25,6 +25,7 @@ import {
} from "@/utils/alerts/anomalyChartQuery";
import { histogramKeyToMicros } from "@/utils/rum/errorIssueUtils";
import { anomalyIntervalSeconds } from "@/components/anomaly_detection/steps/AnomalyDetectionConfig.schema";
import { formatUnitValue, getUnitValue } from "@/utils/dashboard/convertDataIntoUnitValue";
/** One metric-query bucket; every reading is `null` where the bucket has none. */
export interface AnomalyBandRow {
@@ -67,7 +68,10 @@ const BAND_STACK = "band";
const ISOLATED_SYMBOL_SIZE = 6;
const MAX_FRACTION_DIGITS = 6;
const MAX_FRACTION_DIGITS = 12;
// toFixed's ceiling, so the scaled reading keeps every digit the double carries.
const EXACT_DIGITS = 100;
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
@@ -94,16 +98,45 @@ export function formatReading(value: number, fractionDigits?: number): string {
return value.toLocaleString(undefined, { maximumSignificantDigits: 3 });
}
function formatTick(value: number): string {
const magnitude = Math.abs(value);
if (magnitude >= 1) {
return value.toLocaleString(undefined, { maximumFractionDigits: magnitude >= 100 ? 0 : 2 });
}
return value.toLocaleString(undefined, { maximumSignificantDigits: 3 });
}
function formatUnitReading(
value: number,
unit: string,
fmt: (n: number) => string = formatReading,
): string {
const scaled = getUnitValue(value, unit, "", EXACT_DIGITS);
const n = Number(scaled.value);
if (!Number.isFinite(n)) return formatUnitValue(scaled);
const sign = Math.sign(n);
if (sign !== 0 && fmt(n) === fmt(sign * 1000)) {
const next = getUnitValue(sign * (value / n) * 1001, unit, "", 0).unit;
// Only x1000 steps (μs, ms, K, M…) reach 1,000, which is then exactly 1 of the next; s/m/h/D switch lower.
if (next !== scaled.unit) return formatUnitValue({ value: fmt(sign), unit: next });
}
return formatUnitValue({ value: fmt(n), unit: scaled.unit });
}
/** One precision for a value and its bounds: the magnitude default, unless a differing pair would read equal. */
function readingFormatter(value: number | null, bounds: number[]): (x: number) => string {
function readingFormatter(
value: number | null,
bounds: number[],
unit: string,
): (x: number) => string {
const clash = (fmt: (x: number) => string) =>
value !== null && bounds.some((b) => b !== value && fmt(b) === fmt(value));
if (!clash((x) => formatReading(x))) return (x) => formatReading(x);
if (!clash((x) => formatUnitReading(x, unit))) return (x) => formatUnitReading(x, unit);
for (let digits = 0; digits < MAX_FRACTION_DIGITS; digits++) {
const fmt = (x: number) => formatReading(x, digits);
const fmt = (x: number) => formatUnitReading(x, unit, (n) => formatReading(n, digits));
if (!clash(fmt)) return fmt;
}
return (x) => formatReading(x, MAX_FRACTION_DIGITS);
return (x) => formatUnitReading(x, unit, (n) => formatReading(n, MAX_FRACTION_DIGITS));
}
/** Milliseconds of a `histogram_interval` such as "5m"; null when it does not parse. */
@@ -174,7 +207,7 @@ export function bandHeight(row: AnomalyBandRow): number | null {
return row.lower === null || row.upper === null ? null : row.upper - row.lower;
}
function tooltipFormatter(rows: AnomalyBandRow[], labels: AnomalyBandLabels) {
function tooltipFormatter(rows: AnomalyBandRow[], labels: AnomalyBandLabels, unit: string) {
const byTs = new Map(rows.map((row) => [row.tsMs, row]));
return (params: any) => {
const first = Array.isArray(params) ? params[0] : params;
@@ -189,13 +222,15 @@ function tooltipFormatter(rows: AnomalyBandRow[], labels: AnomalyBandLabels) {
lines.push(`${marker}<b>${anomalyLine(row, labels)}</b>`);
}
const bounds = [row.lower, row.upper].filter((b): b is number => b !== null);
const reading = readingFormatter(row.value, bounds);
const reading = readingFormatter(row.value, bounds, unit);
if (row.value !== null) lines.push(`${labels.value}: ${reading(row.value)}`);
if (row.lower !== null && row.upper !== null) {
lines.push(`${labels.range}: [${reading(row.lower)}, ${reading(row.upper)}]`);
}
if (row.expected !== null) lines.push(`${labels.expected}: ${formatReading(row.expected)}`);
if (row.event !== null) lines.push(`${labels.event}: ${formatReading(row.event)}`);
if (row.expected !== null) {
lines.push(`${labels.expected}: ${formatUnitReading(row.expected, unit)}`);
}
if (row.event !== null) lines.push(`${labels.event}: ${formatUnitReading(row.event, unit)}`);
return lines.join("<br/>");
};
}
@@ -206,6 +241,7 @@ export function buildAnomalyBandOptions(
colors: AnomalyBandColors,
span: AnomalyBandWindow,
intervalMs: number | null = null,
unit = "numbers",
) {
const rows = withGapBreaks(bucketRows, intervalMs);
// Stacking is by data index, so every series keeps one entry per row, nulls included.
@@ -227,7 +263,7 @@ export function buildAnomalyBandOptions(
tooltip: {
trigger: "axis",
axisPointer: { type: "line" },
formatter: tooltipFormatter(rows, labels),
formatter: tooltipFormatter(rows, labels, unit),
},
xAxis: {
type: "time",
@@ -240,7 +276,10 @@ export function buildAnomalyBandOptions(
yAxis: {
type: "value",
scale: true,
axisLabel: { color: colors.axis },
axisLabel: {
color: colors.axis,
formatter: (value: number) => formatUnitReading(value, unit, formatTick),
},
splitLine: { lineStyle: { color: colors.grid, type: "dashed" } },
},
series: [
@@ -597,6 +597,17 @@ describe("convertTraceData", () => {
expect(links.find((l: any) => l.target === "gpt-4o")?.source).toBe("ResearchCrew");
});
it("escapes service names in the node tooltip", () => {
const name = "<img src=x onerror=alert(1)>";
const res = convertServiceGraphToNetwork(
{ nodes: [{ id: name, label: name, requests: 1, errors: 0 }], edges: [] },
"force",
);
const formatter = res.options.series[0].data[0].tooltip.formatter;
expect(formatter).toContain("&lt;img src=x onerror=alert(1)&gt;");
expect(formatter).not.toContain("<img");
});
it("should convert service graph to network format", () => {
const graphData = {
nodes: [
+8 -2
View File
@@ -3,6 +3,7 @@ import { computeTreeLayout } from "./computeTreeLayout";
import { resolveModelVendorLogo } from "./modelVendorLogo";
import { cssToken } from "@/utils/theme";
import { gt } from "@/types/i18n";
import { escapeHtml } from "@/utils/html";
import { dataZoomBrushStyle } from "@/utils/chartTheme";
export const convertTraceData = (props: any, timezone: string) => {
const options: any = {
@@ -1259,6 +1260,8 @@ export function getSpanTechIconDataUrl(name: string, isDark: boolean): string |
// ─────────────────────────────────────────────────────────────────────────────
export const MAX_NETWORK_NODE_SYMBOL_SIZE = 110;
/**
* Convert service graph data to ECharts Graph format (force-directed network)
*/
@@ -1410,7 +1413,10 @@ export const convertServiceGraphToNetwork = (
}
// Node size: scales with request volume (70–110 px range)
const baseSymbolSize = Math.max(70, Math.min(110, Math.log10(metrics.requests + 1) * 28));
const baseSymbolSize = Math.max(
70,
Math.min(MAX_NETWORK_NODE_SYMBOL_SIZE, Math.log10(metrics.requests + 1) * 28),
);
// Agent accent treatment (tint/size/ping) applies only when the Agent
// Graph page asks for it; elsewhere an un-highlighted agent renders like any
// other node — downgrade its kind to null so the icon builder skips the
@@ -1462,7 +1468,7 @@ export const convertServiceGraphToNetwork = (
},
tooltip: {
formatter: `
<strong>${node.label || node.id}</strong><br/>
<strong>${escapeHtml(node.label || node.id)}</strong><br/>
${gt("traces.graphTooltip.requests", { value: formatNumber(metrics.requests) })}<br/>
${gt("traces.graphTooltip.errors", { value: formatNumber(metrics.errors) })}<br/>
${gt("traces.graphTooltip.errorRate", { value: errorRate.toFixed(2) })}
+321
View File
@@ -0,0 +1,321 @@
// 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/>.
import { describe, it, expect } from "vitest";
import {
computeCriticalPath,
computeCriticalPathForRoots,
toCriticalPathNode,
type CriticalPathNode,
type CriticalPathSection,
type TraceTreeSpan,
} from "./criticalPath";
interface JaegerSpan {
spanID: string;
startTime: number;
duration: number;
references: { refType: "CHILD_OF"; spanID: string }[];
}
interface JaegerSection {
spanId: string;
section_start: number;
section_end: number;
}
const span = (
spanID: string,
startTime: number,
duration: number,
parent?: string,
): JaegerSpan => ({
spanID,
startTime,
duration,
references: parent ? [{ refType: "CHILD_OF", spanID: parent }] : [],
});
const node = (
spanId: string,
startTimeUs: number,
endTimeUs: number,
children: CriticalPathNode[] = [],
): CriticalPathNode => ({ spanId, startTimeUs, endTimeUs, children });
const section = (
spanId: string,
sectionStartUs: number,
sectionEndUs: number,
): CriticalPathSection => ({ spanId, sectionStartUs, sectionEndUs });
function jaegerToRoots(spans: JaegerSpan[]): CriticalPathNode[] {
const nodes = new Map(
spans.map((s) => [s.spanID, node(s.spanID, s.startTime, s.startTime + s.duration)]),
);
const roots: CriticalPathNode[] = [];
for (const s of spans) {
const parent = s.references[0] && nodes.get(s.references[0].spanID);
if (parent) parent.children.push(nodes.get(s.spanID)!);
else roots.push(nodes.get(s.spanID)!);
}
return roots;
}
const fromJaegerSections = (sections: JaegerSection[]) =>
sections.map((s) => section(s.spanId, s.section_start, s.section_end));
// Fixtures ported from Grafana's CriticalPath/testCases (test5 is FOLLOWS_FROM-only, which the waterfall has no notion of).
const grafanaCases: { name: string; spans: JaegerSpan[]; expected: JaegerSection[] }[] = [
{
name: "test1: sequential children with gaps",
spans: [
span("span-E", 50, 10, "span-C"),
span("span-C", 1, 100),
span("span-D", 20, 20, "span-C"),
],
expected: [
{ spanId: "span-C", section_start: 60, section_end: 101 },
{ spanId: "span-E", section_start: 50, section_end: 60 },
{ spanId: "span-C", section_start: 40, section_end: 50 },
{ spanId: "span-D", section_start: 20, section_end: 40 },
{ spanId: "span-C", section_start: 1, section_end: 20 },
],
},
{
name: "test2: concurrent children, only the last finisher is critical",
spans: [
span("span-X", 1, 100),
span("span-A", 10, 40, "span-X"),
span("span-C", 20, 40, "span-X"),
],
expected: [
{ spanId: "span-X", section_start: 60, section_end: 101 },
{ spanId: "span-C", section_start: 20, section_end: 60 },
{ spanId: "span-X", section_start: 1, section_end: 20 },
],
},
{
name: "test3: child starting after its parent ends is dropped",
spans: [
span("006c3cf93508f205", 1679437737490189, 36),
span("2dc4b796e2127e32", 1679437737491529, 79182, "006c3cf93508f205"),
],
expected: [
{
spanId: "006c3cf93508f205",
section_start: 1679437737490189,
section_end: 1679437737490189 + 36,
},
],
},
{
name: "test4: a dropped child takes its subtree with it",
spans: [
span("span-A", 1, 30),
span("span-B", 40, 40, "span-A"),
span("span-c", 50, 10, "span-B"),
],
expected: [{ spanId: "span-A", section_start: 1, section_end: 31 }],
},
{
name: "test6: grandchild is clamped to its already-clamped parent",
spans: [
span("span-A", 1, 29),
span("span-B", 15, 20, "span-A"),
span("span-C", 10, 15, "span-B"),
],
expected: [
{ spanId: "span-B", section_start: 25, section_end: 30 },
{ spanId: "span-C", section_start: 15, section_end: 25 },
{ spanId: "span-A", section_start: 1, section_end: 15 },
],
},
{
name: "test7: overflowing chain is clamped at every level",
spans: [
span("span-A", 1, 29),
span("span-B", 15, 20, "span-A"),
span("span-C", 20, 20, "span-B"),
],
expected: [
{ spanId: "span-C", section_start: 20, section_end: 30 },
{ spanId: "span-B", section_start: 15, section_end: 20 },
{ spanId: "span-A", section_start: 1, section_end: 15 },
],
},
{
name: "test8: child overflowing both ends of its parent is clamped",
spans: [span("span-A", 10, 20), span("span-B", 5, 30, "span-A")],
expected: [{ spanId: "span-B", section_start: 10, section_end: 30 }],
},
{
name: "test9: child ending before its parent starts is dropped",
spans: [span("span-A", 10, 20), span("span-B", 1, 4, "span-A")],
expected: [{ spanId: "span-A", section_start: 10, section_end: 30 }],
},
];
describe("computeCriticalPath", () => {
describe.each(grafanaCases)("Grafana fixture $name", ({ spans, expected }) => {
it("matches Grafana's critical path sections", () => {
expect(computeCriticalPathForRoots(jaegerToRoots(spans))).toEqual(
fromJaegerSections(expected),
);
});
});
it("covers a single span end to end", () => {
expect(computeCriticalPath(node("root", 5, 25))).toEqual([section("root", 5, 25)]);
});
it("marks only the last finisher of concurrent children as critical", () => {
const root = node("parent", 0, 100_000, [node("A", 0, 80_000), node("B", 0, 50_000)]);
expect(computeCriticalPath(root)).toEqual([
section("parent", 80_000, 100_000),
section("A", 0, 80_000),
]);
});
it("walks every sequential child, unlike a heaviest-chain pick", () => {
// Summing durations picks parent → A → A1 (100+40+40), which skips B entirely.
const root = node("parent", 0, 100, [
node("A", 0, 40, [node("A1", 0, 40)]),
node("B", 45, 100),
]);
expect(computeCriticalPath(root)).toEqual([
section("B", 45, 100),
section("parent", 40, 45),
section("A1", 0, 40),
]);
});
it("treats back-to-back sequential children as all critical", () => {
const root = node("parent", 0, 100, [node("A", 0, 50), node("B", 50, 100)]);
expect(computeCriticalPath(root)).toEqual([section("B", 50, 100), section("A", 0, 50)]);
});
it("clamps a child that overflows its parent", () => {
const root = node("parent", 10, 50, [node("child", 30, 90)]);
expect(computeCriticalPath(root)).toEqual([
section("child", 30, 50),
section("parent", 10, 30),
]);
});
it("drops a child that lies entirely outside its parent", () => {
const root = node("parent", 10, 50, [node("late", 60, 90, [node("grandchild", 70, 80)])]);
expect(computeCriticalPath(root)).toEqual([section("parent", 10, 50)]);
});
describe("multiple roots", () => {
const rooted = (n: CriticalPathNode, parentId: string) => ({ ...n, parentId });
it("walks only the true root, not an orphan whose parent is missing", () => {
const roots = [
rooted(node("orphan", 0, 30), "missing"),
rooted(node("root1", 5, 10, [node("child1", 7, 10)]), ""),
];
expect(computeCriticalPathForRoots(roots)).toEqual([
section("child1", 7, 10),
section("root1", 5, 7),
]);
});
it("walks the earliest-starting span when every root is an orphan", () => {
const roots = Array.from({ length: 15 }, (_, i) =>
rooted(node(`orphan${i}`, 15 - i, 100), `missing${i}`),
);
expect(computeCriticalPathForRoots(roots)).toEqual([section("orphan14", 1, 100)]);
});
it("breaks a start-time tie by the longest duration", () => {
const roots = [
rooted(node("short", 0, 10), ""),
rooted(node("long", 0, 50), ""),
rooted(node("late", 1, 90), ""),
];
expect(computeCriticalPathForRoots(roots)).toEqual([section("long", 0, 50)]);
});
it("walks a lone root even when its parent is missing", () => {
expect(computeCriticalPathForRoots([rooted(node("orphan", 3, 9), "missing")])).toEqual([
section("orphan", 3, 9),
]);
});
});
it("handles 20,000 sequential children without overflowing the stack", () => {
const count = 20_000;
const children = Array.from({ length: count }, (_, i) => node(`c${i}`, i, i + 1));
const root = node("parent", 0, count, children);
const sections = computeCriticalPath(root);
expect(sections).toHaveLength(count);
expect(sections[0]).toEqual(section(`c${count - 1}`, count - 1, count));
expect(sections[count - 1]).toEqual(section("c0", 0, 1));
});
});
describe("toCriticalPathNode", () => {
const treeSpan = (spanId: string, start: number, end: number, spans: TraceTreeSpan[] = []) => ({
spanId,
startTimeUs: start,
endTimeUs: end,
spans,
});
it("keeps ids, times and child order", () => {
const tree = treeSpan("root", 0, 10, [
treeSpan("a", 1, 2),
treeSpan("b", 3, 9, [treeSpan("c", 4, 5)]),
]);
expect(toCriticalPathNode(tree)).toEqual(
node("root", 0, 10, [node("a", 1, 2), node("b", 3, 9, [node("c", 4, 5)])]),
);
});
it("keeps the root's parent id so orphan roots can be told apart", () => {
expect(toCriticalPathNode({ ...treeSpan("orphan", 0, 10), parentId: "missing" }).parentId).toBe(
"missing",
);
});
it("converts a 20,000-deep chain without overflowing the stack", () => {
const depth = 20_000;
const root = treeSpan("s0", 0, depth);
let parent = root;
for (let i = 1; i < depth; i++) {
const child = treeSpan(`s${i}`, i, depth);
parent.spans.push(child);
parent = child;
}
const sections = computeCriticalPath(toCriticalPathNode(root));
expect(sections).toHaveLength(depth);
expect(sections[0]).toEqual(section(`s${depth - 1}`, depth - 1, depth));
});
});
+167
View File
@@ -0,0 +1,167 @@
// 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/>.
// Ported from the Jaeger UI critical-path algorithm (Apache-2.0), as adapted by Grafana.
export interface CriticalPathSection {
spanId: string;
sectionStartUs: number;
sectionEndUs: number;
}
export interface CriticalPathNode {
spanId: string;
startTimeUs: number;
endTimeUs: number;
/** Non-empty on a root marks an orphan whose parent is missing from the trace. */
parentId?: string;
children: CriticalPathNode[];
}
export interface TraceTreeSpan {
spanId: string;
startTimeUs: number;
endTimeUs: number;
parentId?: string;
spans: TraceTreeSpan[];
}
interface WindowNode {
spanId: string;
start: number;
end: number;
children: WindowNode[];
}
interface WalkFrame {
node: WindowNode;
cursor: number;
}
/** Critical path of one span tree, in walk order (latest section first). */
export function computeCriticalPath(root: CriticalPathNode): CriticalPathSection[] {
const sections: CriticalPathSection[] = [];
const stack: WalkFrame[] = [{ node: sanitise(root), cursor: 0 }];
let returnStart: number | undefined;
while (stack.length) {
const frame = stack[stack.length - 1];
const { node } = frame;
const sectionEnd = returnStart ?? node.end;
const child = nextLastFinishingChild(frame, returnStart);
if (child) {
pushSection(sections, node.spanId, child.end, sectionEnd);
stack.push({ node: child, cursor: 0 });
returnStart = undefined;
} else {
pushSection(sections, node.spanId, node.start, sectionEnd);
stack.pop();
returnStart = node.start;
}
}
return sections;
}
/** Critical path of the trace's primary root only; other roots get no sections. */
export function computeCriticalPathForRoots(roots: CriticalPathNode[]): CriticalPathSection[] {
const primary = primaryRoot(roots);
return primary ? computeCriticalPath(primary) : [];
}
/** Iterative, so trace depth never bounds the call stack. */
export function toCriticalPathNode(span: TraceTreeSpan): CriticalPathNode {
const toNode = (s: TraceTreeSpan): CriticalPathNode => ({
spanId: s.spanId,
startTimeUs: s.startTimeUs,
endTimeUs: s.endTimeUs,
parentId: s.parentId,
children: [],
});
const root = toNode(span);
const stack: [TraceTreeSpan, CriticalPathNode][] = [[span, root]];
while (stack.length) {
const [raw, node] = stack.pop()!;
for (const child of raw.spans) {
const converted = toNode(child);
node.children.push(converted);
stack.push([child, converted]);
}
}
return root;
}
function primaryRoot(roots: CriticalPathNode[]): CriticalPathNode | undefined {
const trueRoots = roots.filter((r) => !r.parentId);
const candidates = trueRoots.length ? trueRoots : roots;
return [...candidates].sort(
(a, b) =>
a.startTimeUs - b.startTimeUs || b.endTimeUs - b.startTimeUs - (a.endTimeUs - a.startTimeUs),
)[0];
}
/** Clamps children into their parent's window (dropping disjoint subtrees) and orders them latest-ending first. */
function sanitise(root: CriticalPathNode): WindowNode {
const top = toWindowNode(root, root.startTimeUs, root.endTimeUs);
const stack: [CriticalPathNode, WindowNode][] = [[root, top]];
while (stack.length) {
const [raw, parent] = stack.pop()!;
for (const child of raw.children) {
const outside =
child.startTimeUs >= parent.end ||
(child.startTimeUs < parent.start && child.endTimeUs <= parent.start);
if (outside) continue;
const clamped = toWindowNode(child, parent.start, parent.end);
parent.children.push(clamped);
stack.push([child, clamped]);
}
parent.children.sort((a, b) => b.end - a.end);
}
return top;
}
function toWindowNode(node: CriticalPathNode, minStart: number, maxEnd: number): WindowNode {
const start = Math.max(node.startTimeUs, minStart);
return {
spanId: node.spanId,
start,
end: Math.max(start, Math.min(node.endTimeUs, maxEnd)),
children: [],
};
}
function nextLastFinishingChild(
frame: WalkFrame,
returnStart: number | undefined,
): WindowNode | undefined {
const { children } = frame.node;
// Children are end-sorted and returnStart only moves earlier, so a skipped child never qualifies later.
while (frame.cursor < children.length) {
const child = children[frame.cursor++];
if (returnStart === undefined || child.end <= returnStart) return child;
}
return undefined;
}
function pushSection(
sections: CriticalPathSection[],
spanId: string,
sectionStartUs: number,
sectionEndUs: number,
) {
if (sectionStartUs !== sectionEndUs) sections.push({ spanId, sectionStartUs, sectionEndUs });
}
+1 -1
View File
@@ -237,7 +237,7 @@ export function buildPatternConsolidatedTree(traceTree: any[]): Map<string, Call
/**
* Check if a span represents an error
*/
function isSpanError(span: any): boolean {
export function isSpanError(span: any): boolean {
// Check span status (supports both snake_case from API and camelCase from formatted spans)
if (span.status_code === 2 || span.span_status === "ERROR" || span.spanStatus === "ERROR") {
return true;
@@ -0,0 +1,240 @@
// 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/>.
import { describe, expect, it } from "vitest";
import {
buildTraceServiceGraph,
filterTraceServiceGraph,
filterTraceTree,
} from "./traceServiceGraph";
const span = (
span_id: string,
service_name: string,
reference_parent_span_id = "",
extra: Record<string, unknown> = {},
) => ({ span_id, service_name, reference_parent_span_id, span_status: "UNSET", ...extra });
describe("buildTraceServiceGraph", () => {
it("returns an empty graph for an empty span list", () => {
expect(buildTraceServiceGraph([])).toEqual({ nodes: [], edges: [] });
});
it("builds a single node for a single-service trace", () => {
expect(buildTraceServiceGraph([span("a", "api"), span("b", "api", "a")])).toEqual({
nodes: [
{
id: "api",
label: "api",
requests: 1,
errors: 0,
service_type: undefined,
},
],
edges: [],
});
});
it("draws an edge for each parent→child service hop in a chain", () => {
const graph = buildTraceServiceGraph([
span("a", "web"),
span("b", "api", "a"),
span("c", "db", "b"),
]);
expect(graph.nodes.map((n) => n.id)).toEqual(["api", "db", "web"]);
expect(graph.edges).toEqual([
{ from: "api", to: "db", total_requests: 1 },
{ from: "web", to: "api", total_requests: 1 },
]);
});
it("aggregates repeated calls between the same services", () => {
const graph = buildTraceServiceGraph([
span("a", "web"),
span("b", "api", "a"),
span("c", "api", "a"),
span("d", "api", "a"),
]);
expect(graph.edges).toEqual([{ from: "web", to: "api", total_requests: 3 }]);
expect(graph.nodes.find((n) => n.id === "api")?.requests).toBe(3);
});
it("counts only entries into a service as its requests", () => {
const graph = buildTraceServiceGraph([
span("a", "web"),
span("b", "api", "a"),
span("c", "api", "b"),
span("d", "api", "c"),
]);
expect(graph.nodes.map((n) => [n.id, n.requests])).toEqual([
["api", 1],
["web", 1],
]);
});
it("counts errored entry spans as node errors, ignoring internal spans", () => {
const graph = buildTraceServiceGraph([
span("a", "web"),
span("b", "api", "a", { span_status: "ERROR" }),
span("c", "api", "a", { status_code: 2 }),
span("d", "api", "a"),
span("e", "api", "a"),
span("f", "api", "b", { span_status: "ERROR" }),
]);
const api = graph.nodes.find((n) => n.id === "api")!;
expect(api.requests).toBe(4);
expect(api.errors).toBe(2);
expect(graph.edges[0]).toEqual({
from: "web",
to: "api",
total_requests: 4,
});
});
it("names inferred services by their inferred identity and carries the service type", () => {
const graph = buildTraceServiceGraph([
span("a", "api"),
span("b", "api", "a", { infer_service_name: "postgres", infer_service_type: "database" }),
]);
expect(graph.nodes).toEqual([
{ id: "api", label: "api", requests: 1, errors: 0, service_type: undefined },
{
id: "postgres",
label: "postgres",
requests: 1,
errors: 0,
service_type: "database",
},
]);
expect(graph.edges.map((e) => [e.from, e.to])).toEqual([["api", "postgres"]]);
});
it("names spans without any service by the given unknown-service label", () => {
const graph = buildTraceServiceGraph([span("a", "web"), span("b", "", "a")], "Unknown Service");
expect(graph.nodes.map((n) => n.id)).toEqual(["Unknown Service", "web"]);
expect(graph.edges.map((e) => [e.from, e.to])).toEqual([["web", "Unknown Service"]]);
});
it("adds no edge for a span whose parent is not in the trace", () => {
const graph = buildTraceServiceGraph([span("a", "web"), span("b", "api", "missing")]);
expect(graph.nodes.map((n) => n.id)).toEqual(["api", "web"]);
expect(graph.edges).toEqual([]);
});
it("adds no edge for a call within the same service", () => {
const graph = buildTraceServiceGraph([
span("a", "api"),
span("b", "api", "a"),
span("c", "db", "b"),
]);
expect(graph.edges.map((e) => [e.from, e.to])).toEqual([["api", "db"]]);
});
});
describe("filterTraceServiceGraph", () => {
const graph = buildTraceServiceGraph([
span("a", "web"),
span("b", "api", "a"),
span("c", "db", "b"),
span("d", "Cache", "a"),
]);
it("returns the graph unchanged for an empty search", () => {
expect(filterTraceServiceGraph(graph, " ")).toEqual(graph);
});
it("keeps edges touching a matching node and the nodes on those edges", () => {
const filtered = filterTraceServiceGraph(graph, "DB");
expect(filtered.edges.map((e) => [e.from, e.to])).toEqual([["api", "db"]]);
expect(filtered.nodes.map((n) => n.id)).toEqual(["api", "db"]);
});
it("matches case-insensitively", () => {
expect(filterTraceServiceGraph(graph, "cache").nodes.map((n) => n.id)).toEqual([
"Cache",
"web",
]);
});
it("keeps a matching node that has no edges", () => {
const withOrphan = buildTraceServiceGraph([span("a", "web"), span("b", "batch", "missing")]);
expect(filterTraceServiceGraph(withOrphan, "batch")).toEqual({
nodes: [withOrphan.nodes[0]],
edges: [],
});
expect(withOrphan.nodes[0].id).toBe("batch");
});
it("returns an empty graph when nothing matches", () => {
expect(filterTraceServiceGraph(graph, "nope")).toEqual({ nodes: [], edges: [] });
});
});
describe("filterTraceTree", () => {
const tree = [
{
name: "web",
children: [
{ name: "api", children: [{ name: "db", children: [{ name: "disk" }] }] },
{ name: "cache" },
],
},
{ name: "queue" },
];
it("returns the tree unchanged for an empty search", () => {
expect(filterTraceTree(tree, "")).toEqual(tree);
});
it("keeps a match with its ancestors and direct children and drops other branches", () => {
expect(filterTraceTree(tree, "DB")).toEqual([
{
name: "web",
children: [{ name: "api", children: [{ name: "db", children: [{ name: "disk" }] }] }],
},
]);
});
it("drops a match's unmatched descendants beyond its direct children", () => {
expect(filterTraceTree(tree, "api")).toEqual([
{ name: "web", children: [{ name: "api", children: [{ name: "db", children: undefined }] }] },
]);
expect(filterTraceTree(tree, "web")).toEqual([
{
name: "web",
children: [
{ name: "api", children: undefined },
{ name: "cache", children: undefined },
],
},
]);
});
it("applies the rule again to a matching descendant of a match", () => {
expect(filterTraceTree(tree, "b")).toEqual([
{
name: "web",
children: [
{ name: "api", children: [{ name: "db", children: [{ name: "disk" }] }] },
{ name: "cache", children: undefined },
],
},
]);
});
it("returns an empty list when nothing matches", () => {
expect(filterTraceTree(tree, "nope")).toEqual([]);
});
});
+139
View File
@@ -0,0 +1,139 @@
// 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/>.
import type { Span } from "@/ts/interfaces/traces/span.types";
import { isSpanError } from "@/utils/traces/patternDetection";
import { useSpanServiceDetection } from "@/utils/traces/useSpanServiceDetection";
export interface TraceServiceGraphNode {
id: string;
label: string;
requests: number;
errors: number;
service_type?: string;
}
export interface TraceServiceGraphEdge {
from: string;
to: string;
total_requests: number;
}
export interface TraceServiceGraph {
nodes: TraceServiceGraphNode[];
edges: TraceServiceGraphEdge[];
}
interface TraceGraphSpan {
span_id?: string;
reference_parent_span_id?: string;
service_name?: string;
infer_service_name?: string;
infer_service_system?: string;
infer_service_type?: string;
span_status?: string;
status_code?: number;
}
interface NamedTreeNode {
name: string;
children?: NamedTreeNode[];
}
export function buildTraceServiceGraph(
spans: TraceGraphSpan[],
unknownServiceLabel = "unknown",
): TraceServiceGraph {
const { resolveSpanIdentity } = useSpanServiceDetection();
const identityBySpanId = new Map<string, string>();
const nodes = new Map<string, TraceServiceGraphNode>();
const edges = new Map<string, TraceServiceGraphEdge>();
const identities = spans.map((span) => resolveSpanIdentity(span as Span) || unknownServiceLabel);
spans.forEach((span, i) => {
const id = identities[i];
if (span.span_id) identityBySpanId.set(span.span_id, id);
const node = nodes.get(id) ?? {
id,
label: id,
requests: 0,
errors: 0,
service_type: undefined,
};
if (!node.service_type && (span.infer_service_name || span.infer_service_system)) {
node.service_type = span.infer_service_type || undefined;
}
nodes.set(id, node);
});
spans.forEach((span, i) => {
const to = identities[i];
const from = identityBySpanId.get(span.reference_parent_span_id ?? "");
if (from === to) return;
// Only entries into a service count as its requests; its internal child spans do not.
const node = nodes.get(to)!;
node.requests += 1;
if (isSpanError(span)) node.errors += 1;
if (!from) return;
const key = `${from}\u0000${to}`;
const edge = edges.get(key) ?? {
from,
to,
total_requests: 0,
};
edge.total_requests += 1;
edges.set(key, edge);
});
return {
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
edges: [...edges.values()].sort(
(a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to),
),
};
}
/** ServiceGraph.vue `applyFilters` rule, but isolated matching nodes are also kept. */
export function filterTraceServiceGraph(
graph: TraceServiceGraph,
search: string,
): TraceServiceGraph {
const trimmed = search.trim().toLowerCase();
if (!trimmed) return graph;
const matchingNodeIds = new Set(
graph.nodes.filter((n) => n.label.toLowerCase().includes(trimmed)).map((n) => n.id),
);
const edges = graph.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)]);
return {
nodes: graph.nodes.filter((n) => matchingNodeIds.has(n.id) || usedNodeIds.has(n.id)),
edges,
};
}
export function filterTraceTree<T extends NamedTreeNode>(nodes: T[], search: string): T[] {
const trimmed = search.trim().toLowerCase();
if (!trimmed) return nodes;
const prune = (list: T[], parentMatched: boolean): T[] =>
list.flatMap((node) => {
const matched = node.name.toLowerCase().includes(trimmed);
const children = prune((node.children ?? []) as T[], matched);
if (matched || children.length) {
return [{ ...node, children: children.length ? children : undefined }];
}
return parentMatched ? [{ ...node, children: undefined }] : [];
});
return prune(nodes, false);
}